feat: android bundling, red/blue exe names, session cache (#3608)

* feat: android bundling

* fix: add hashing to exe names for red/blue exes

* feat: implement session cache for window restoration
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-01-20 21:16:55 -08:00
1 parent 024285c548
commit d342630a0a
11 files changed
+334 -185

No files matched your search

+13
View File
@@ -67,6 +67,7 @@ pub const APP_TITLE_ENV: &str = "DIOXUS_APP_TITLE";
#[deprecated(since = "0.6.0", note = "The CLI currently does not set this.")]
#[doc(hidden)]
pub const OUT_DIR: &str = "DIOXUS_OUT_DIR";
pub const SESSION_CACHE_DIR: &str = "DIOXUS_SESSION_CACHE_DIR";
/// Reads an environment variable at runtime in debug mode or at compile time in
/// release mode. When bundling in release mode, we will not be running under the
@@ -277,3 +278,15 @@ pub fn out_dir() -> Option<PathBuf> {
std::env::var(OUT_DIR).ok().map(PathBuf::from)
}
}
/// Get the directory where this app can write to for this session that's guaranteed to be stable
/// between reloads of the same app. This is useful for emitting state like window position and size
/// so the app can restore it when it's next opened.
///
/// Note that this cache dir is really only useful for platforms that can access it. Web/Android
/// don't have access to this directory, so it's not useful for them.
///
/// This is designed with desktop executables in mind.
pub fn session_cache_dir() -> Option<PathBuf> {
std::env::var(SESSION_CACHE_DIR).ok().map(PathBuf::from)
}
+57 -17
View File
@@ -730,23 +730,7 @@ impl AppBundle {
if let Platform::Android = self.build.build.platform() {
self.build.status_running_gradle();
// make sure we can execute the gradlew script
#[cfg(unix)]
{
use std::os::unix::prelude::PermissionsExt;
std::fs::set_permissions(
self.build.root_dir().join("gradlew"),
std::fs::Permissions::from_mode(0o755),
)?;
}
let gradle_exec_name = match cfg!(windows) {
true => "gradlew.bat",
false => "gradlew",
};
let gradle_exec = self.build.root_dir().join(gradle_exec_name);
let output = Command::new(gradle_exec)
let output = Command::new(self.gradle_exe()?)
.arg("assembleDebug")
.current_dir(self.build.root_dir())
.stderr(std::process::Stdio::piped())
@@ -762,6 +746,62 @@ impl AppBundle {
Ok(())
}
/// Run bundleRelease and return the path to the `.aab` file
///
/// https://stackoverflow.com/questions/57072558/whats-the-difference-between-gradlewassemblerelease-gradlewinstallrelease-and
pub(crate) async fn android_gradle_bundle(&self) -> Result<PathBuf> {
let output = Command::new(self.gradle_exe()?)
.arg("bundleRelease")
.current_dir(self.build.root_dir())
.output()
.await
.context("Failed to run gradle bundleRelease")?;
if !output.status.success() {
return Err(anyhow::anyhow!("Failed to bundleRelease: {output:?}").into());
}
let app_release = self
.build
.root_dir()
.join("app")
.join("build")
.join("outputs")
.join("bundle")
.join("release");
// Rename it to Name-arch.aab
let from = app_release.join("app-release.aab");
let to = app_release.join(format!(
"{}-{}.aab",
self.build.krate.bundled_app_name(),
self.build.build.target_args.arch()
));
std::fs::rename(from, &to).context("Failed to rename aab")?;
Ok(to)
}
fn gradle_exe(&self) -> Result<PathBuf> {
// make sure we can execute the gradlew script
#[cfg(unix)]
{
use std::os::unix::prelude::PermissionsExt;
std::fs::set_permissions(
self.build.root_dir().join("gradlew"),
std::fs::Permissions::from_mode(0o755),
)?;
}
let gradle_exec_name = match cfg!(windows) {
true => "gradlew.bat",
false => "gradlew",
};
Ok(self.build.root_dir().join(gradle_exec_name))
}
pub(crate) fn apk_path(&self) -> PathBuf {
self.build
.root_dir()
+5 -4
View File
@@ -76,11 +76,12 @@ impl Bundle {
Platform::Server => bundles.push(bundle.build.root_dir()),
Platform::Liveview => bundles.push(bundle.build.root_dir()),
// todo(jon): we can technically create apks (already do...) just need to expose it
Platform::Android => {
return Err(Error::UnsupportedFeature(
"Android bundles are not yet supported".into(),
));
let aab = bundle
.android_gradle_bundle()
.await
.context("Failed to run gradle bundleRelease")?;
bundles.push(aab);
}
};
+1 -1
View File
@@ -46,7 +46,7 @@ impl RunArgs {
tracing::info!("[{platform}]: {msg}")
}
ServeUpdate::ProcessExited { platform, status } => {
runner.kill(platform).await;
runner.cleanup().await;
tracing::info!("[{platform}]: process exited with status: {status:?}");
break;
}
+12
View File
@@ -217,3 +217,15 @@ impl TryFrom<String> for Arch {
}
}
}
impl std::fmt::Display for Arch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Arch::Arm => "armv7l",
Arch::Arm64 => "aarch64",
Arch::X86 => "i386",
Arch::X64 => "x86_64",
}
.fmt(f)
}
}
+10
View File
@@ -149,6 +149,16 @@ impl DioxusCrate {
files
}
/// Get the directory where this app can write to for this session that's guaranteed to be stable
/// for the same app. This is useful for emitting state like window position and size.
///
/// The directory is specific for this app and might be
pub(crate) fn session_cache_dir(&self) -> PathBuf {
self.internal_out_dir()
.join(self.executable_name())
.join("session-cache")
}
/// Get the outdir specified by the Dioxus.toml, relative to the crate directory.
/// We don't support workspaces yet since that would cause a collision of bundles per project.
pub(crate) fn crate_out_dir(&self) -> Option<PathBuf> {
+1
View File
@@ -33,6 +33,7 @@ pub(crate) enum Error {
#[error("Failed to bundle project: {0}")]
BundleFailed(#[from] tauri_bundler::Error),
#[allow(unused)]
#[error("Unsupported feature: {0}")]
UnsupportedFeature(String),
+153 -2
View File
@@ -38,6 +38,11 @@ pub(crate) struct AppHandle {
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>,
@@ -54,6 +59,8 @@ impl AppHandle {
server_child: None,
server_stdout: None,
server_stderr: None,
entropy_app_exe: None,
entropy_server_exe: None,
})
}
@@ -79,6 +86,15 @@ impl AppHandle {
// 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) = &self.app.build.krate.config.web.app.base_path {
@@ -87,7 +103,7 @@ impl AppHandle {
// 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.app.server_exe()) {
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()));
@@ -147,6 +163,70 @@ impl AppHandle {
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) {
tracing::debug!("Cleaning up process");
// 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
@@ -236,12 +316,15 @@ impl AppHandle {
///
/// Server/liveview/desktop are all basically the same, though
fn open_with_main_exe(&mut self, envs: Vec<(&str, String)>) -> Result<Child> {
let child = Command::new(self.app.main_exe())
// 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)
}
@@ -650,4 +733,72 @@ We checked the folder: {}
};
});
}
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
}
}
+2 -13
View File
@@ -107,11 +107,6 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
} else if runner.should_full_rebuild {
tracing::info!(dx_src = ?TraceSrc::Dev, "Full rebuild: {}", file);
// Kill any running executables on Windows
if cfg!(windows) {
runner.kill_all().await;
}
// We're going to kick off a new build, interrupting the current build if it's ongoing
builder.rebuild(args.build_arguments.clone());
@@ -199,8 +194,6 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
- To exit the server, press `ctrl+c`"#
);
}
runner.kill(platform).await;
}
ServeUpdate::StdoutReceived { platform, msg } => {
@@ -221,11 +214,6 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
// `Hotreloading:` to keep the alignment during long edit sessions
tracing::info!("Full rebuild: triggered manually");
// Kill any running executables on Windows
if cfg!(windows) {
runner.kill_all().await;
}
builder.rebuild(args.build_arguments.clone());
runner.file_map.force_rebuild();
devserver.send_reload_start().await;
@@ -261,9 +249,10 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
}
};
_ = runner.cleanup().await;
_ = devserver.shutdown().await;
_ = screen.shutdown();
builder.abort_all();
_ = screen.shutdown();
if let Err(err) = err {
eprintln!("Exiting with error: {}", err);
+78 -132
View File
@@ -5,17 +5,16 @@ use crate::{
use dioxus_core::internal::TemplateGlobalKey;
use dioxus_devtools_types::HotReloadMsg;
use dioxus_html::HtmlCtx;
use futures_util::{future::OptionFuture, stream::FuturesUnordered, FutureExt};
use futures_util::future::OptionFuture;
use ignore::gitignore::Gitignore;
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::PathBuf,
};
use tokio_stream::StreamExt;
pub(crate) struct AppRunner {
pub(crate) running: HashMap<Platform, AppHandle>,
pub(crate) running: Option<AppHandle>,
pub(crate) krate: DioxusCrate,
pub(crate) file_map: HotreloadFilemap,
pub(crate) ignore: Gitignore,
@@ -44,52 +43,47 @@ impl AppRunner {
runner.fill_filemap(krate);
}
// Ensure the session cache dir exists and is empty
runner.flush_session_cache();
runner
}
pub(crate) async fn wait(&mut self) -> ServeUpdate {
// If there are no running apps, we can just return pending to avoid deadlocking
if self.running.is_empty() {
let Some(handle) = self.running.as_mut() else {
return futures_util::future::pending().await;
}
};
self.running
.iter_mut()
.map(|(platform, handle)| async {
use ServeUpdate::*;
let platform = *platform;
tokio::select! {
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stdout.as_mut().map(|f| f.next_line())) => {
StdoutReceived { platform, msg }
},
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stderr.as_mut().map(|f| f.next_line())) => {
StderrReceived { platform, msg }
},
Some(status) = OptionFuture::from(handle.app_child.as_mut().map(|f| f.wait())) => {
match status {
Ok(status) => ProcessExited { status, platform },
Err(_err) => todo!("handle error in process joining?"),
}
}
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stdout.as_mut().map(|f| f.next_line())) => {
StdoutReceived { platform: Platform::Server, msg }
},
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stderr.as_mut().map(|f| f.next_line())) => {
StderrReceived { platform: Platform::Server, msg }
},
Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => {
match status {
Ok(status) => ProcessExited { status, platform },
Err(_err) => todo!("handle error in process joining?"),
}
}
else => futures_util::future::pending().await
use ServeUpdate::*;
let platform = handle.app.build.build.platform();
tokio::select! {
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stdout.as_mut().map(|f| f.next_line())) => {
StdoutReceived { platform, msg }
},
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stderr.as_mut().map(|f| f.next_line())) => {
StderrReceived { platform, msg }
},
Some(status) = OptionFuture::from(handle.app_child.as_mut().map(|f| f.wait())) => {
match status {
Ok(status) => ProcessExited { status, platform },
Err(_err) => todo!("handle error in process joining?"),
}
})
.collect::<FuturesUnordered<_>>()
.next()
.await
.expect("Stream to pending if not empty")
}
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stdout.as_mut().map(|f| f.next_line())) => {
StdoutReceived { platform: Platform::Server, msg }
},
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stderr.as_mut().map(|f| f.next_line())) => {
StderrReceived { platform: Platform::Server, msg }
},
Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => {
match status {
Ok(status) => ProcessExited { status, platform },
Err(_err) => todo!("handle error in process joining?"),
}
}
else => futures_util::future::pending().await
}
}
/// Finally "bundle" this app and return a handle to it
@@ -100,16 +94,9 @@ impl AppRunner {
fullstack_address: Option<SocketAddr>,
should_open_web: bool,
) -> Result<&AppHandle> {
let platform = app.build.build.platform();
// Drop the old handle
// todo(jon): we should instead be sending the kill signal rather than dropping the process
// This would allow a more graceful shutdown and fix bugs like desktop not retaining its size
self.kill(platform).await;
// wait a tiny sec for the processes to die so we don't have fullstack servers on top of each other
// todo(jon): we should allow rebinding to the same port in fullstack itself
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// This is a more forceful kill than soft_kill since the app entropy will be wiped
self.cleanup().await;
// Add some cute logging
if self.builds_opened == 0 {
@@ -132,81 +119,32 @@ impl AppRunner {
.await?;
self.builds_opened += 1;
self.running.insert(platform, handle);
self.running = Some(handle);
Ok(self.running.get(&platform).unwrap())
}
/// 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.
pub(crate) async fn kill(&mut self, platform: Platform) {
use tokio::process::Command;
let Some(mut process) = self.running.remove(&platform) else {
return;
};
let server_process = process.server_child.take();
let client_process = process.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() => {}
};
}
}
pub(crate) async fn kill_all(&mut self) {
let keys = self.running.keys().cloned().collect::<Vec<_>>();
for platform in keys {
self.kill(platform).await;
}
Ok(self.running.as_ref().unwrap())
}
/// Open an existing app bundle, if it exists
pub(crate) async fn open_existing(&mut self, devserver: &WebServer) -> Result<()> {
let fullstack_address = devserver.proxied_server_address();
if let Some((_, app)) = self
.running
.iter_mut()
.find(|(platform, _)| **platform != Platform::Server)
{
app.open(devserver.devserver_address(), fullstack_address, true)
if let Some(runner) = self.running.as_mut() {
runner.soft_kill().await;
runner
.open(devserver.devserver_address(), fullstack_address, true)
.await?;
}
Ok(())
}
/// Shutdown all the running processes
pub(crate) async fn cleanup(&mut self) {
if let Some(mut process) = self.running.take() {
process.cleanup().await;
}
}
pub(crate) async fn attempt_hot_reload(
&mut self,
modified_files: Vec<PathBuf>,
@@ -237,7 +175,7 @@ impl AppRunner {
}
// Otherwise, it might be an asset and we should look for it in all the running apps
for runner in self.running.values() {
if let Some(runner) = self.running.as_mut() {
if let Some(bundled_name) = runner.hotreload_bundled_asset(&path).await {
// todo(jon): don't hardcode this here
let asset_relative = PathBuf::from("/assets/").join(bundled_name);
@@ -329,27 +267,29 @@ impl AppRunner {
}
pub(crate) async fn client_connected(&mut self) {
for (platform, runner) in self.running.iter_mut() {
// Assign the runtime asset dir to the runner
if *platform == Platform::Ios {
// xcrun simctl get_app_container booted com.dioxuslabs
let res = tokio::process::Command::new("xcrun")
.arg("simctl")
.arg("get_app_container")
.arg("booted")
.arg(runner.app.build.krate.bundle_identifier())
.output()
.await;
let Some(handle) = self.running.as_mut() else {
return;
};
if let Ok(res) = res {
tracing::trace!("Using runtime asset dir: {:?}", res);
// Assign the runtime asset dir to the runner
if handle.app.build.build.platform() == Platform::Ios {
// xcrun simctl get_app_container booted com.dioxuslabs
let res = tokio::process::Command::new("xcrun")
.arg("simctl")
.arg("get_app_container")
.arg("booted")
.arg(handle.app.build.krate.bundle_identifier())
.output()
.await;
if let Ok(out) = String::from_utf8(res.stdout) {
let out = out.trim();
if let Ok(res) = res {
tracing::trace!("Using runtime asset dir: {:?}", res);
tracing::trace!("Setting Runtime asset dir: {out:?}");
runner.runtime_asst_dir = Some(PathBuf::from(out));
}
if let Ok(out) = String::from_utf8(res.stdout) {
let out = out.trim();
tracing::trace!("Setting Runtime asset dir: {out:?}");
handle.runtime_asst_dir = Some(PathBuf::from(out));
}
}
}
@@ -388,4 +328,10 @@ impl AppRunner {
}
}
}
fn flush_session_cache(&self) {
let cache_dir = self.krate.session_cache_dir();
_ = std::fs::remove_dir_all(&cache_dir);
_ = std::fs::create_dir_all(&cache_dir);
}
}
+2 -16
View File
@@ -593,20 +593,6 @@ fn hide_last_window(window: &Window) {
/// Return the location of a tempfile with our window state in it such that we can restore it later
fn restore_file() -> std::path::PathBuf {
/// Get the name of the program or default to "dioxus" so we can hash it
fn get_prog_name_or_default() -> Option<String> {
Some(
std::env::current_exe()
.ok()?
.file_name()?
.to_str()?
.to_string(),
)
}
let name = get_prog_name_or_default().unwrap_or_else(|| "dioxus".to_string());
let hashed_id = name.chars().map(|c| c as usize).sum::<usize>();
let mut path = std::env::temp_dir();
path.push(format!("{}-window-state.json", hashed_id));
path
let dir = dioxus_cli_config::session_cache_dir().unwrap_or_else(std::env::temp_dir);
dir.join("window-state.json")
}