Fix WSL Hot Reload (#2721)
* feat: poll watcher * progress: wsl hot reload setting * feat: wsl poll setting
This commit is contained in:
1 parent
4e338accad
commit
b7127ad6ae
5 files changed
+159
-62
No files matched your search
@@ -26,51 +26,47 @@ pub enum Config {
|
|||||||
/// Create a custom html file.
|
/// Create a custom html file.
|
||||||
CustomHtml {},
|
CustomHtml {},
|
||||||
|
|
||||||
/// Set global cli settings.
|
/// Set CLI settings.
|
||||||
SetGlobal { setting: Setting, value: Value },
|
#[command(subcommand)]
|
||||||
|
Set(Setting),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, clap::ValueEnum)]
|
#[derive(Debug, Clone, Copy, Deserialize, Subcommand)]
|
||||||
pub enum Setting {
|
pub enum Setting {
|
||||||
/// Set the value of the always-hot-reload setting.
|
/// Set the value of the always-hot-reload setting.
|
||||||
AlwaysHotReload,
|
AlwaysHotReload { value: BoolValue },
|
||||||
/// Set the value of the always-open-browser setting.
|
/// Set the value of the always-open-browser setting.
|
||||||
AlwaysOpenBrowser,
|
AlwaysOpenBrowser { value: BoolValue },
|
||||||
/// Set the value of the always-on-top desktop setting.
|
/// Set the value of the always-on-top desktop setting.
|
||||||
AlwaysOnTop,
|
AlwaysOnTop { value: BoolValue },
|
||||||
|
/// Set the interval that file changes are polled on WSL for hot reloading.
|
||||||
|
WSLFilePollInterval { value: u16 },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Setting {
|
impl Display for Setting {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlwaysHotReload => write!(f, "always_hot_reload"),
|
Self::AlwaysHotReload { value: _ } => write!(f, "always-hot-reload"),
|
||||||
Self::AlwaysOpenBrowser => write!(f, "always_open_browser"),
|
Self::AlwaysOpenBrowser { value: _ } => write!(f, "always-open-browser"),
|
||||||
Self::AlwaysOnTop => write!(f, "always_on_top"),
|
Self::AlwaysOnTop { value: _ } => write!(f, "always-on-top"),
|
||||||
|
Self::WSLFilePollInterval { value: _ } => write!(f, "wsl-file-poll-interval"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Unsure of an alternative to get the desired behavior with clap, if it exists.
|
// Clap complains if we use a bool directly and I can't find much info about it.
|
||||||
|
// "Argument 'value` is positional and it must take a value but action is SetTrue"
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, clap::ValueEnum)]
|
#[derive(Debug, Clone, Copy, Deserialize, clap::ValueEnum)]
|
||||||
pub enum Value {
|
pub enum BoolValue {
|
||||||
True,
|
True,
|
||||||
False,
|
False,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Value {
|
impl From<BoolValue> for bool {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn from(value: BoolValue) -> Self {
|
||||||
match self {
|
|
||||||
Self::True => write!(f, "true"),
|
|
||||||
Self::False => write!(f, "false"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Value> for bool {
|
|
||||||
fn from(value: Value) -> Self {
|
|
||||||
match value {
|
match value {
|
||||||
Value::True => true,
|
BoolValue::True => true,
|
||||||
Value::False => false,
|
BoolValue::False => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,14 +107,21 @@ impl Config {
|
|||||||
file.write_all(content.as_bytes())?;
|
file.write_all(content.as_bytes())?;
|
||||||
tracing::info!("🚩 Create custom html file done.");
|
tracing::info!("🚩 Create custom html file done.");
|
||||||
}
|
}
|
||||||
// Handle configuration of global CLI settings.
|
// Handle CLI settings.
|
||||||
Config::SetGlobal { setting, value } => {
|
Config::Set(setting) => {
|
||||||
CliSettings::modify_settings(|settings| match setting {
|
CliSettings::modify_settings(|settings| match setting {
|
||||||
Setting::AlwaysHotReload => settings.always_hot_reload = Some(value.into()),
|
Setting::AlwaysOnTop { value } => settings.always_on_top = Some(value.into()),
|
||||||
Setting::AlwaysOpenBrowser => settings.always_open_browser = Some(value.into()),
|
Setting::AlwaysHotReload { value } => {
|
||||||
Setting::AlwaysOnTop => settings.always_on_top = Some(value.into()),
|
settings.always_hot_reload = Some(value.into())
|
||||||
|
}
|
||||||
|
Setting::AlwaysOpenBrowser { value } => {
|
||||||
|
settings.always_open_browser = Some(value.into())
|
||||||
|
}
|
||||||
|
Setting::WSLFilePollInterval { value } => {
|
||||||
|
settings.wsl_file_poll_interval = Some(value)
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
tracing::info!("🚩 CLI setting `{setting}` has been set to `{value}`")
|
tracing::info!("🚩 CLI setting `{setting}` has been set.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct ServeArguments {
|
|||||||
/// Additional arguments to pass to the executable
|
/// Additional arguments to pass to the executable
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
|
|
||||||
|
/// Sets the interval in seconds that the CLI will poll for file changes on WSL.
|
||||||
|
#[clap(long, default_missing_value = "2")]
|
||||||
|
pub wsl_file_poll_interval: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the WASM project on dev-server
|
/// Run the WASM project on dev-server
|
||||||
@@ -59,15 +63,26 @@ pub struct Serve {
|
|||||||
impl Serve {
|
impl Serve {
|
||||||
/// Resolve the serve arguments from the arguments or the config
|
/// Resolve the serve arguments from the arguments or the config
|
||||||
fn resolve(&mut self, crate_config: &mut DioxusCrate) -> Result<()> {
|
fn resolve(&mut self, crate_config: &mut DioxusCrate) -> Result<()> {
|
||||||
// Set config settings
|
// Set config settings.
|
||||||
let settings = settings::CliSettings::load();
|
let settings = settings::CliSettings::load();
|
||||||
|
|
||||||
|
// Enable hot reload.
|
||||||
if self.server_arguments.hot_reload.is_none() {
|
if self.server_arguments.hot_reload.is_none() {
|
||||||
self.server_arguments.hot_reload = Some(settings.always_hot_reload.unwrap_or(true));
|
self.server_arguments.hot_reload = Some(settings.always_hot_reload.unwrap_or(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open browser.
|
||||||
if self.server_arguments.open.is_none() {
|
if self.server_arguments.open.is_none() {
|
||||||
self.server_arguments.open = Some(settings.always_open_browser.unwrap_or_default());
|
self.server_arguments.open = Some(settings.always_open_browser.unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set WSL file poll interval.
|
||||||
|
if self.server_arguments.wsl_file_poll_interval.is_none() {
|
||||||
|
self.server_arguments.wsl_file_poll_interval =
|
||||||
|
Some(settings.wsl_file_poll_interval.unwrap_or(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set always-on-top for desktop.
|
||||||
if self.server_arguments.always_on_top.is_none() {
|
if self.server_arguments.always_on_top.is_none() {
|
||||||
self.server_arguments.always_on_top = Some(settings.always_on_top.unwrap_or(true))
|
self.server_arguments.always_on_top = Some(settings.always_on_top.unwrap_or(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub async fn serve_all(serve: Serve, dioxus_crate: DioxusCrate) -> Result<()> {
|
|||||||
builder.build();
|
builder.build();
|
||||||
|
|
||||||
let mut server = Server::start(&serve, &dioxus_crate);
|
let mut server = Server::start(&serve, &dioxus_crate);
|
||||||
let mut watcher = Watcher::start(&dioxus_crate);
|
let mut watcher = Watcher::start(&serve, &dioxus_crate);
|
||||||
let mut screen = Output::start(&serve).expect("Failed to open terminal logger");
|
let mut screen = Output::start(&serve).expect("Failed to open terminal logger");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
use std::path::PathBuf;
|
use std::{fs, path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use crate::dioxus_crate::DioxusCrate;
|
|
||||||
use crate::serve::hot_reloading_file_map::FileMap;
|
use crate::serve::hot_reloading_file_map::FileMap;
|
||||||
|
use crate::{cli::serve::Serve, dioxus_crate::DioxusCrate};
|
||||||
use dioxus_hot_reload::HotReloadMsg;
|
use dioxus_hot_reload::HotReloadMsg;
|
||||||
use dioxus_html::HtmlCtx;
|
use dioxus_html::HtmlCtx;
|
||||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use ignore::gitignore::Gitignore;
|
use ignore::gitignore::Gitignore;
|
||||||
use notify::{event::ModifyKind, EventKind, RecommendedWatcher};
|
use notify::{
|
||||||
|
event::{MetadataKind, ModifyKind},
|
||||||
|
Config, EventKind,
|
||||||
|
};
|
||||||
|
|
||||||
/// This struct stores the file watcher and the filemap for the project.
|
/// This struct stores the file watcher and the filemap for the project.
|
||||||
///
|
///
|
||||||
@@ -17,14 +20,14 @@ pub struct Watcher {
|
|||||||
_tx: UnboundedSender<notify::Event>,
|
_tx: UnboundedSender<notify::Event>,
|
||||||
rx: UnboundedReceiver<notify::Event>,
|
rx: UnboundedReceiver<notify::Event>,
|
||||||
_last_update_time: i64,
|
_last_update_time: i64,
|
||||||
_watcher: RecommendedWatcher,
|
_watcher: Box<dyn notify::Watcher>,
|
||||||
queued_events: Vec<notify::Event>,
|
queued_events: Vec<notify::Event>,
|
||||||
file_map: FileMap,
|
file_map: FileMap,
|
||||||
ignore: Gitignore,
|
ignore: Gitignore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Watcher {
|
impl Watcher {
|
||||||
pub fn start(config: &DioxusCrate) -> Self {
|
pub fn start(serve: &Serve, config: &DioxusCrate) -> Self {
|
||||||
let (tx, rx) = futures_channel::mpsc::unbounded();
|
let (tx, rx) = futures_channel::mpsc::unbounded();
|
||||||
|
|
||||||
// Extend the watch path to include:
|
// Extend the watch path to include:
|
||||||
@@ -61,27 +64,41 @@ impl Watcher {
|
|||||||
}
|
}
|
||||||
let ignore = builder.build().unwrap();
|
let ignore = builder.build().unwrap();
|
||||||
|
|
||||||
// Create the file watcher
|
// Build the event handler for notify.
|
||||||
let mut watcher = notify::recommended_watcher({
|
let notify_event_handler = {
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
move |info: notify::Result<notify::Event>| {
|
move |info: notify::Result<notify::Event>| {
|
||||||
if let Ok(e) = info {
|
if let Ok(e) = info {
|
||||||
match e.kind {
|
if is_allowed_notify_event(&e) {
|
||||||
|
_ = tx.unbounded_send(e);
|
||||||
// An event emitted when the metadata of a file or folder is changed.
|
|
||||||
EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any) |
|
|
||||||
EventKind::Create(_) |
|
|
||||||
EventKind::Remove(_) => {
|
|
||||||
_ = tx.unbounded_send(e);
|
|
||||||
},
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.expect("Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.");
|
|
||||||
|
// 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
|
// Watch the specified paths
|
||||||
// todo: make sure we don't double-watch paths if they're nested
|
// todo: make sure we don't double-watch paths if they're nested
|
||||||
@@ -95,7 +112,6 @@ impl Watcher {
|
|||||||
|
|
||||||
let mode = notify::RecursiveMode::Recursive;
|
let mode = notify::RecursiveMode::Recursive;
|
||||||
|
|
||||||
use notify::Watcher;
|
|
||||||
if let Err(err) = watcher.watch(path, mode) {
|
if let Err(err) = watcher.watch(path, mode) {
|
||||||
tracing::warn!("Failed to watch path: {}", err);
|
tracing::warn!("Failed to watch path: {}", err);
|
||||||
}
|
}
|
||||||
@@ -144,14 +160,9 @@ impl Watcher {
|
|||||||
|
|
||||||
// Decompose the events into a list of all the files that have changed
|
// Decompose the events into a list of all the files that have changed
|
||||||
for event in self.queued_events.drain(..) {
|
for event in self.queued_events.drain(..) {
|
||||||
// We only care about modify/crate/delete events
|
// We only care about certain events.
|
||||||
match event.kind {
|
if !is_allowed_notify_event(&event) {
|
||||||
EventKind::Modify(ModifyKind::Any) => {}
|
continue;
|
||||||
EventKind::Modify(ModifyKind::Data(_)) => {}
|
|
||||||
EventKind::Modify(ModifyKind::Name(_)) => {}
|
|
||||||
EventKind::Create(_) => {}
|
|
||||||
EventKind::Remove(_) => {}
|
|
||||||
_ => continue,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
@@ -276,6 +287,55 @@ fn is_backup_file(path: PathBuf) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tests if the provided [`notify::Event`] is something we listen to so we can avoid unescessary hot reloads.
|
||||||
|
fn is_allowed_notify_event(event: ¬ify::Event) -> bool {
|
||||||
|
match event.kind {
|
||||||
|
EventKind::Modify(ModifyKind::Data(_)) => true,
|
||||||
|
EventKind::Modify(ModifyKind::Name(_)) => true,
|
||||||
|
EventKind::Create(_) => true,
|
||||||
|
EventKind::Remove(_) => true,
|
||||||
|
// The primary modification event on WSL's poll watcher.
|
||||||
|
EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true,
|
||||||
|
// Catch-all for unknown event types.
|
||||||
|
EventKind::Modify(ModifyKind::Any) => true,
|
||||||
|
// Don't care about anything else.
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WSL_1: &str = "/proc/sys/kernel/osrelease";
|
||||||
|
const WSL_2: &str = "/proc/version";
|
||||||
|
const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"];
|
||||||
|
|
||||||
|
/// Detects if `dx` is being ran in a WSL environment.
|
||||||
|
///
|
||||||
|
/// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the [`WSL_1`] or [`WSL_2`] files.
|
||||||
|
/// This may fail in the future as it isn't guaranteed by Microsoft.
|
||||||
|
/// See https://github.com/microsoft/WSL/issues/423#issuecomment-221627364
|
||||||
|
fn is_wsl() -> bool {
|
||||||
|
// Test 1st File
|
||||||
|
if let Ok(content) = fs::read_to_string(WSL_1) {
|
||||||
|
let lowercase = content.to_lowercase();
|
||||||
|
for keyword in WSL_KEYWORDS {
|
||||||
|
if lowercase.contains(keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2nd File
|
||||||
|
if let Ok(content) = fs::read_to_string(WSL_2) {
|
||||||
|
let lowercase = content.to_lowercase();
|
||||||
|
for keyword in WSL_KEYWORDS {
|
||||||
|
if lowercase.contains(keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_backup_file() {
|
fn test_is_backup_file() {
|
||||||
assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
|
assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub struct CliSettings {
|
|||||||
pub always_open_browser: Option<bool>,
|
pub always_open_browser: Option<bool>,
|
||||||
/// Describes whether desktop apps in development will be pinned always-on-top.
|
/// Describes whether desktop apps in development will be pinned always-on-top.
|
||||||
pub always_on_top: Option<bool>,
|
pub always_on_top: Option<bool>,
|
||||||
|
/// Describes the interval in seconds that the CLI should poll for file changes on WSL.
|
||||||
|
#[serde(default = "default_wsl_file_poll_interval")]
|
||||||
|
pub wsl_file_poll_interval: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliSettings {
|
impl CliSettings {
|
||||||
@@ -74,7 +77,19 @@ impl CliSettings {
|
|||||||
CrateConfigError::Io(Error::new(ErrorKind::Other, e.to_string()))
|
CrateConfigError::Io(Error::new(ErrorKind::Other, e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let result = fs::write(path.clone(), data.clone());
|
// Create the directory structure if it doesn't exist.
|
||||||
|
let parent_path = path.parent().unwrap();
|
||||||
|
if let Err(e) = fs::create_dir_all(parent_path) {
|
||||||
|
error!(
|
||||||
|
?data,
|
||||||
|
?path,
|
||||||
|
"failed to create directories for settings file"
|
||||||
|
);
|
||||||
|
return Err(CrateConfigError::Io(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the data.
|
||||||
|
let result = fs::write(&path, data.clone());
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
error!(?data, ?path, "failed to save global cli settings");
|
error!(?data, ?path, "failed to save global cli settings");
|
||||||
return Err(CrateConfigError::Io(e));
|
return Err(CrateConfigError::Io(e));
|
||||||
@@ -102,3 +117,7 @@ impl CliSettings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_wsl_file_poll_interval() -> Option<u16> {
|
||||||
|
Some(2)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user