diff --git a/Cargo.lock b/Cargo.lock index 8138d5d44..4481ac402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3578,6 +3578,7 @@ version = "0.7.5" name = "dioxus-desktop" version = "0.7.5" dependencies = [ + "anyhow", "async-trait", "base64 0.22.1", "bytes", @@ -3602,6 +3603,7 @@ dependencies = [ "generational-box", "global-hotkey", "http-range", + "image", "infer", "jni 0.21.1", "lazy-js-bundle", diff --git a/_typos.toml b/_typos.toml index 81648ac4e..2c90856dc 100644 --- a/_typos.toml +++ b/_typos.toml @@ -14,4 +14,4 @@ unparented = "unparented" flate2="flate2" [files] -extend-exclude = ["notes/translations/*", "CHANGELOG.md", "*.js"] +extend-exclude = ["notes/translations/*", "CHANGELOG.md", "*.js", "packages/cli/src/build/windows.rs"] diff --git a/packages/cli/assets/icon.ico b/packages/cli/assets/icon.ico new file mode 100644 index 000000000..243747b8d Binary files /dev/null and b/packages/cli/assets/icon.ico differ diff --git a/packages/cli/schema.json b/packages/cli/schema.json index 73a61448f..aa147decf 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -541,6 +541,13 @@ "null" ] }, + "name": { + "description": "Display name of the application", + "type": [ + "string", + "null" + ] + }, "out_dir": { "type": [ "string", @@ -649,6 +656,12 @@ "type": "string" } }, + "file_version": { + "type": [ + "string", + "null" + ] + }, "icon": { "type": [ "array", @@ -680,6 +693,12 @@ } ] }, + "original_file_name": { + "type": [ + "string", + "null" + ] + }, "publisher": { "type": [ "string", @@ -701,6 +720,18 @@ "null" ] }, + "trademark": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, "windows": { "anyOf": [ { @@ -2864,6 +2895,13 @@ "WindowsSettings": { "type": "object", "properties": { + "add_toolkit_include": { + "description": "Pass the host Windows SDK's `um/` and `shared/` headers to `rc.exe` via `/I` flags. Only relevant when cross-compiling to MSVC from a non-Windows host where the SDK headers aren't on the default include path. Has no effect for the GNU (`windres`) toolchain.", + "type": [ + "boolean", + "null" + ] + }, "allow_downgrades": { "type": "boolean" }, @@ -2873,18 +2911,48 @@ "null" ] }, + "comments": { + "description": "Free-form developer comments embedded in the executable's VERSIONINFO, surfaced in Windows Explorer → Properties → Details → Comments.", + "type": [ + "string", + "null" + ] + }, "digest_algorithm": { "type": [ "string", "null" ] }, + "extra_rc": { + "description": "Raw `.rc` snippet appended verbatim to the generated resource file. Use this to add custom resources not covered by other settings — application manifests, custom string tables, accelerators, etc. Contents are not escaped.", + "type": [ + "string", + "null" + ] + }, "icon_path": { "type": [ "string", "null" ] }, + "internal_name": { + "description": "Internal name embedded in the executable's VERSIONINFO. Most apps leave this equal to the product name; override only if the binary is known internally by a different identifier (e.g. an original codename).", + "type": [ + "string", + "null" + ] + }, + "language": { + "description": "VERSIONINFO LANGID for the resource block. Hex form like `0x0409` (en-US) or `0x0407` (de-DE). Defaults to `0x0000` (neutral). See for the full table.", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + }, "nsis": { "anyOf": [ { diff --git a/packages/cli/src/build/mod.rs b/packages/cli/src/build/mod.rs index a017995a8..3cf49a4ce 100644 --- a/packages/cli/src/build/mod.rs +++ b/packages/cli/src/build/mod.rs @@ -12,6 +12,7 @@ mod android; mod apple; mod web; +mod windows; mod assets; mod builder; diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 9e3090618..652ffaa14 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -943,6 +943,15 @@ impl BuildRequest { AndroidTools::unpack_prebuilt_openssl()?; } + // Compile the Windows resource (.res) so it's ready to be linked in later + if matches!(self.triple.operating_system, OperatingSystem::Windows) { + if let Err(err) = self.write_winres() { + if self.using_dioxus_explicitly { + tracing::warn!("Application may not have an icon: {err}"); + } + } + } + Ok(()) } @@ -1767,6 +1776,11 @@ impl BuildRequest { cargo_args.push("-Clink-arg=-Wl,-rpath,$ORIGIN/../lib".to_string()); cargo_args.push("-Clink-arg=-Wl,-rpath,$ORIGIN".to_string()); } + OperatingSystem::Windows => { + if let Some((search_path, link_spec)) = self.winres_linker_args() { + cargo_args.extend(["-L".to_string(), search_path, "-l".to_string(), link_spec]); + } + } _ => {} } @@ -2505,7 +2519,7 @@ impl BuildRequest { /// target/dx/build/app/web/ /// target/dx/build/app/web/public/ /// target/dx/build/app/web/server.exe - fn platform_dir(&self) -> PathBuf { + pub(crate) fn platform_dir(&self) -> PathBuf { self.internal_out_dir() .join(&self.main_target) .join(if self.release { "release" } else { "debug" }) diff --git a/packages/cli/src/build/windows.rs b/packages/cli/src/build/windows.rs new file mode 100644 index 000000000..a327ba585 --- /dev/null +++ b/packages/cli/src/build/windows.rs @@ -0,0 +1,779 @@ +//! Modified version of or +//! +//! Rust Windows resource helper +//! +//! This crate implements a simple generator for Windows resource (.rc) files +//! for use with either Microsoft `rc.exe` resource compiler or with GNU `windres.exe` +//! +//! Maybe it could be replaced by in the future +//! if it's going to be used to compile resources as the name suggests instead of external tools + +use krates::semver::Version; + +use anyhow::{anyhow, Result}; +use std::collections::HashMap; +use std::env; +use std::fs::File; +use std::io; +use std::io::prelude::*; +use std::path::{Path, PathBuf}; +use std::process; +use target_lexicon::{Architecture, Environment, OperatingSystem, Triple, X86_32Architecture}; + +use crate::BuildRequest; + +const DEFAULT_ICON: &[u8] = include_bytes!("../../assets/icon.ico"); + +impl BuildRequest { + /// Per-build directory for the compiled Windows resource (`.rc` source, + /// the produced object/lib, and any default-icon copy). Lives under + /// [`Self::platform_dir`], which is already unique per + /// `(project, profile, bundle)` and is not cleared by `dx build`/`dx bundle`, + /// so multiple projects can't clobber each other. + pub(crate) fn winres_dir(&self) -> PathBuf { + self.platform_dir().join(".winres") + } + + /// Returns `(search_path, link_spec)` for the Windows resource library + /// produced by [`Self::write_winres`], if it has been built. This is a + /// pure lookup that derives the deterministic output paths and only + /// returns `Some` when the compiled lib is actually present on disk, so + /// it is safe to call from `cargo_build_arguments`. The pair is suitable + /// for `cargo rustc -L -l `. + pub(crate) fn winres_linker_args(&self) -> Option<(String, String)> { + if !matches!(self.triple.operating_system, OperatingSystem::Windows) { + return None; + } + + let winres_dir = self.winres_dir(); + + let (link_spec, lib_filename) = if matches!(self.triple.environment, Environment::Msvc) { + ("dylib=resource", "resource.lib") + } else { + ("static=resource", "libresource.a") + }; + + if !winres_dir.join(lib_filename).is_file() { + return None; + } + + Some(( + winres_dir.to_string_lossy().to_string(), + link_spec.to_string(), + )) + } + + // needs to only run when tomls are updated + pub(crate) fn write_winres(&self) -> Result<()> { + let bundle = &self.config.bundle; + let package = self.package(); + + let (version_str, version) = match bundle.version.as_ref() { + Some(v) => (v, VersionInfo::version_from_str(v)), + None => ( + &format!( + "{}.{}.{}", + package.version.major, package.version.minor, package.version.patch + ), + VersionInfo::version_from_krate(&package.version), + ), + }; + + let (file_version_str, file_version) = match bundle.file_version.as_ref() { + Some(v) => (v, VersionInfo::version_from_str(v)), + None => (version_str, version), + }; + + let productname = match self.config.application.name.as_ref() { + Some(n) => n, + None => &self.bundled_app_name(), + }; + + let binding = package.description.clone().unwrap_or_default(); + let description = match bundle.short_description.as_ref() { + Some(val) => val, + None => bundle.long_description.as_ref().unwrap_or(&binding), + }; + + let winres_dir = self.winres_dir(); + _ = std::fs::create_dir_all(&winres_dir); + + let mut winres = WindowsResource::new(version, file_version); + winres + .set(Properties::ProductVersion, version_str) + .set(Properties::FileVersion, file_version_str) + .set(Properties::ProductName, productname) + .set(Properties::FileDescription, description); + + if let Some(value) = &bundle.original_file_name { + winres.set(Properties::OriginalFilename, value); + } + + if let Some(value) = &bundle.copyright { + winres.set(Properties::Copyright, value); + } + if let Some(value) = &bundle.trademark { + winres.set(Properties::Trademark, value); + } + + if let Some(value) = &bundle.publisher { + winres.set(Properties::CompanyName, value); + } + + if let Some(value) = &bundle.category { + winres.set(Properties::Other("Category"), value); + } + + let windows = bundle.windows.as_ref(); + + if let Some(value) = windows.and_then(|w| w.comments.as_deref()) { + winres.set(Properties::Comments, value); + } + if let Some(value) = windows.and_then(|w| w.internal_name.as_deref()) { + winres.set(Properties::InternalName, value); + } + if let Some(language) = windows.and_then(|w| w.language) { + winres.set_language(language); + } + if windows.and_then(|w| w.add_toolkit_include).unwrap_or(false) { + winres.add_toolkit_include(true); + } + if let Some(value) = windows.and_then(|w| w.extra_rc.as_deref()) { + winres.append_rc_content(value); + } + + let mut has_default = false; + if let Some(path) = windows.and_then(|w| w.icon_path.as_ref()) { + winres.set_icon(path.clone()); + has_default = true; + } + + if let Some(icons) = bundle.icon.as_ref() { + for (id, icon) in icons.iter().enumerate() { + if icon.ends_with(".ico") { + let icon_path = self.canonicalize_icon_path(&PathBuf::from(icon))?; + if !has_default { + // first .ico file will be app icon + winres.set_icon(icon_path); + has_default = true; + } else { + // other .ico files can be accessed with their index as id + winres.set_icon_with_id(icon_path, id.to_string()); + }; + } + } + } + + if !has_default { + let default_icon = winres_dir.join("icon.ico"); + let mut file = File::create(&default_icon)?; + file.write_all(DEFAULT_ICON)?; + let icon = self.canonicalize_icon_path(&default_icon)?; + winres.set_icon(icon); + } + + winres.compile(&self.triple, &winres_dir)?; + Ok(()) + } + + fn canonicalize_icon_path(&self, icon_path: &PathBuf) -> Result { + if icon_path.is_absolute() && icon_path.is_file() { + return Ok(dunce::canonicalize(icon_path)?); + } + let crate_icon = self.crate_dir().join(icon_path); + if crate_icon.is_file() { + return Ok(dunce::canonicalize(crate_icon)?); + } + let workspace_icon = self.workspace_dir().join(icon_path); + if workspace_icon.is_file() { + return Ok(dunce::canonicalize(workspace_icon)?); + } + + Err(anyhow::anyhow!( + "Could not find icon from path {:?}", + icon_path + )) + } +} + +/// Values based on +/// use to_str() +#[allow(clippy::upper_case_acronyms)] +#[derive(PartialEq, Eq, Hash, Debug, Clone, Copy)] +pub enum IDI { + APPLICATION = 32512, + ERROR = 32513, + QUESTION = 32514, + WARNING = 32515, + INFORMATION = 32516, + WINLOGO = 32517, + SHIELD = 32518, +} + +impl std::fmt::Display for IDI { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u32) + } +} + +impl From for String { + fn from(e: IDI) -> Self { + e.to_string() + } +} + +/// Version info field names +#[allow(clippy::upper_case_acronyms)] +#[derive(PartialEq, Eq, Hash, Debug)] +pub enum VersionInfo { + /// The version value consists of four 16 bit words, e.g., + /// `MAJOR << 48 | MINOR << 32 | PATCH << 16 | RELEASE` + FILEVERSION, + /// The version value consists of four 16 bit words, e.g., + /// `MAJOR << 48 | MINOR << 32 | PATCH << 16 | RELEASE` + PRODUCTVERSION, + /// Should be Windows NT Win32, with value `0x40004` + FILEOS, + /// The value (for a rust compiler output) should be + /// 1 for a EXE and 2 for a DLL + FILETYPE, + /// Only for Windows drivers + FILESUBTYPE, + /// Bit mask for FILEFLAGS + FILEFLAGSMASK, + /// Only the bits set in FILEFLAGSMASK are read + FILEFLAGS, +} + +/// Common properties fields +/// +#[derive(PartialEq, Eq, Hash, Debug)] +pub enum Properties { + FileVersion, + FileDescription, + ProductVersion, + ProductName, + OriginalFilename, + Copyright, + Trademark, + CompanyName, + Comments, + InternalName, + /// Additionally there exists + /// `"PrivateBuild"`, `"SpecialBuild"` + /// which should only be set, when the `FILEFLAGS` property is set to + /// `VS_FF_PRIVATEBUILD(0x08)` or `VS_FF_SPECIALBUILD(0x20)` + /// + /// It is possible to use arbitrary field names but Windows Explorer and other + /// tools might not show them. + Other(&'static str), +} + +impl std::fmt::Display for Properties { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Properties::FileVersion => write!(f, "FileVersion"), + Properties::FileDescription => write!(f, "FileDescription"), + Properties::ProductVersion => write!(f, "ProductVersion"), + Properties::ProductName => write!(f, "ProductName"), + Properties::OriginalFilename => write!(f, "OriginalFilename"), + Properties::Copyright => write!(f, "LegalCopyright"), + Properties::Trademark => write!(f, "LegalTrademark"), + Properties::CompanyName => write!(f, "CompanyName"), + Properties::Comments => write!(f, "Comments"), + Properties::InternalName => write!(f, "InternalName"), + Properties::Other(other) => write!(f, "{}", other), + } + } +} + +impl VersionInfo { + /// Creates u64 version from string + fn version_from_str(version: &str) -> u64 { + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() > 4 { + tracing::warn!("Version number had more than 4 parts. Ignoring the rest."); + } + let mut segments = [0u16; 4]; + for (i, part) in parts.iter().take(4).enumerate() { + match part.parse::() { + Ok(value) => segments[i] = value, + Err(e) => { + tracing::warn!( + "Could not parse segment {} '{}' as u16: {}. Defaulting to 0.", + i, + part, + e + ); + } + } + } + + ((segments[0] as u64) << 48) + | ((segments[1] as u64) << 32) + | ((segments[2] as u64) << 16) + | (segments[3] as u64) + } + + fn version_from_krate(v: &Version) -> u64 { + (v.major << 48) | (v.minor << 32) | (v.patch << 16) + } +} + +#[derive(Debug)] +struct Icon { + path: String, + name_id: String, +} + +#[derive(Debug)] +struct WindowsResource { + properties: HashMap, + version_info: HashMap, + icons: Vec, + language: u16, + add_toolkit_include: bool, + append_rc_content: String, +} + +impl WindowsResource { + /// Create a new resource with version info struct + /// + /// We initialize the resource file with values provided in Dioxus.toml + /// + fn new(version: u64, file_version: u64) -> Self { + let props: HashMap = HashMap::new(); + let mut ver: HashMap = HashMap::new(); + + ver.insert(VersionInfo::FILEVERSION, file_version); + ver.insert(VersionInfo::PRODUCTVERSION, version); + ver.insert(VersionInfo::FILEOS, 0x00040004); // VOS_NT_WINDOWS32 (0x40004) + ver.insert(VersionInfo::FILETYPE, 1); // VFT_APP (0x1) + ver.insert(VersionInfo::FILESUBTYPE, 0); // VFT2_UNKNOWN (0x0) + ver.insert(VersionInfo::FILEFLAGSMASK, 0x3F); // VS_FFI_FILEFLAGSMASK (0x3F) + ver.insert(VersionInfo::FILEFLAGS, 0); // 0x0 + + WindowsResource { + properties: props, + version_info: ver, + icons: Vec::new(), + language: 0, + add_toolkit_include: false, + append_rc_content: String::new(), + } + } + + /// Set string properties of the version info struct. + /// + /// See [`Properties`] for valid values + fn set(&mut self, name: Properties, value: &str) -> &mut Self { + self.properties.insert(name.to_string(), value.to_string()); + self + } + + /// Set the user interface language of the file + /// + /// For possible values look at the `winapi::um::winnt` constants, specifically those + /// starting with `LANG_` and `SUBLANG_` or at + /// + /// [`MAKELANGID`]: https://docs.rs/winapi/0.3/x86_64-pc-windows-msvc/winapi/um/winnt/fn.MAKELANGID.html + /// [`winapi::um::winnt`]: https://docs.rs/winapi/0.3/x86_64-pc-windows-msvc/winapi/um/winnt/index.html#constants + /// + /// # Table + /// Sometimes it is just simpler to specify the numeric constant directly + /// (That is what most `.rc` files do). + /// For possible values take a look at the MSDN page for resource files; + /// we only listed some values here. + /// + /// | Language | Value | + /// |---------------------|----------| + /// | Neutral | `0x0000` | + /// | English | `0x0009` | + /// | English (US) | `0x0409` | + /// | English (GB) | `0x0809` | + /// | German | `0x0407` | + /// | German (AT) | `0x0c07` | + /// | French | `0x000c` | + /// | French (FR) | `0x040c` | + /// | Catalan | `0x0003` | + /// | Basque | `0x042d` | + /// | Breton | `0x007e` | + /// | Scottish Gaelic | `0x0091` | + /// | Romansch | `0x0017` | + fn set_language(&mut self, language: u16) -> &mut Self { + self.language = language; + self + } + + /// Add an icon with nameID `1`. + /// + /// This icon need to be in `ico` format. The filename can be absolute + /// or relative to the projects root. + /// + /// Equivalent ```to set_icon_with_id(path, IDI::APPLICATION)```. + fn set_icon(&mut self, path: PathBuf) -> &mut Self { + self.set_icon_with_id(path, IDI::APPLICATION) + } + + /// Add an icon with the specified name ID. + /// + /// This icon need to be in `ico` format. The path can be absolute or + /// relative to the projects root. + /// + /// ## Name ID and Icon Loading + /// + /// The name ID can be (the string representation of) a 16-bit unsigned + /// integer, or some other string. + /// + /// You should not add multiple icons with the same name ID. It will result + /// in a build failure. + /// + /// When the name ID is an integer, the icon can be loaded at runtime with + /// + /// ```ignore + /// LoadIconW(h_instance, MAKEINTRESOURCEW(name_id_as_integer)) + /// ``` + /// + /// Otherwise, it can be loaded with + /// + /// ```ignore + /// LoadIconW(h_instance, name_id_as_wide_c_str_as_ptr) + /// ``` + /// + /// Where `h_instance` is the module handle of the current executable + /// ([`GetModuleHandleW`](https://docs.rs/winapi/0.3.8/winapi/um/libloaderapi/fn.GetModuleHandleW.html)`(null())`), + /// [`LoadIconW`](https://docs.rs/winapi/0.3.8/winapi/um/winuser/fn.LoadIconW.html) + /// and + /// [`MAKEINTRESOURCEW`](https://docs.rs/winapi/0.3.8/winapi/um/winuser/fn.MAKEINTRESOURCEW.html) + /// are defined in winapi. + /// + /// ## Multiple Icons, Which One is Application Icon? + /// + /// When you have multiple icons, it's a bit complicated which one will be + /// chosen as the application icon: + /// . + /// + /// To keep things simple, we recommend you use only 16-bit unsigned integer + /// name IDs, and add the application icon first with the lowest id: + /// + /// see [`IDI`] for special icons ids + fn set_icon_with_id(&mut self, path: PathBuf, name_id: impl Into) -> &mut Self { + self.icons.push(Icon { + path: path.to_string_lossy().to_string(), + name_id: name_id.into(), + }); + self + } + + /// Set the path to the ar executable. + fn add_toolkit_include(&mut self, add: bool) -> &mut Self { + self.add_toolkit_include = add; + self + } + + /// Write a resource file with the set values + fn write_resource_file(&self, path: &Path) -> Result { + let path = path.join("resource.rc"); + let mut f = File::create(&path)?; + + // use UTF8 as an encoding + // this makes it easier since in rust all string are UTF8 + writeln!(f, "#pragma code_page(65001)")?; + writeln!(f, "1 VERSIONINFO")?; + for (k, v) in self.version_info.iter() { + match *k { + VersionInfo::FILEVERSION | VersionInfo::PRODUCTVERSION => writeln!( + f, + "{:?} {}, {}, {}, {}", + k, + (*v >> 48) as u16, + (*v >> 32) as u16, + (*v >> 16) as u16, + *v as u16 + )?, + _ => writeln!(f, "{:?} {:#x}", k, v)?, + }; + } + writeln!(f, "{{\nBLOCK \"StringFileInfo\"")?; + writeln!(f, "{{\nBLOCK \"{:04x}04b0\"\n{{", self.language)?; + for (k, v) in self.properties.iter() { + if !v.is_empty() { + writeln!( + f, + "VALUE \"{}\", \"{}\"", + escape_string_windows(k), + escape_string_windows(v) + )?; + } + } + writeln!(f, "}}\n}}")?; + + writeln!(f, "BLOCK \"VarFileInfo\" {{")?; + writeln!(f, "VALUE \"Translation\", {:#x}, 0x04b0", self.language)?; + writeln!(f, "}}\n}}")?; + for icon in &self.icons { + writeln!( + f, + "{} ICON \"{}\"", + escape_string_windows(&icon.name_id), + escape_string_windows(&icon.path) + )?; + } + + writeln!(f, "{}", self.append_rc_content)?; + Ok(path) + } + + /// Append an additional snippet to the generated rc file. + fn append_rc_content(&mut self, content: &str) -> &mut Self { + if !(self.append_rc_content.ends_with('\n') || self.append_rc_content.is_empty()) { + self.append_rc_content.push('\n'); + } + self.append_rc_content.push_str(content); + self + } + + /// Run the resource compiler + /// + /// This function generates a resource file from the settings or + /// uses an existing resource file and passes it to the resource compiler + /// of your toolkit. + fn compile(&mut self, target: &Triple, output_dir: &Path) -> Result<()> { + if matches!(target.environment, Environment::Msvc) { + tracing::debug!("Compiling Windows resource file with msvc toolkit"); + self.compile_with_toolkit_msvc(target, output_dir) + } else if target.environment.to_string().contains("gnu") { + tracing::debug!("Compiling Windows resource file with gnu toolkit"); + self.compile_with_toolkit_gnu(target, output_dir) + } else { + Err(anyhow!( + "Can only compile resource file when target_env is 'gnu' or 'msvc'", + )) + } + } + + fn compile_with_toolkit_gnu(&mut self, target: &Triple, output_dir: &Path) -> Result<()> { + let toolkit_path = + if env::var_os("HOST").is_some_and(|v| v.to_string_lossy().contains("windows")) { + PathBuf::from("\\") + } else { + PathBuf::from("/") + }; + + let prefix = if env::var_os("HOST") + .zip(env::var_os("TARGET")) + .is_some_and(|(h, t)| h != t) + { + match (target.architecture, target.environment) { + // Standard MinGW-w64 targets + (Architecture::X86_64, Environment::Gnu) => "x86_64-w64-mingw32-", + (Architecture::X86_32(X86_32Architecture::I686), Environment::Gnu) => { + "i686-w64-mingw32-" + } + (Architecture::X86_32(X86_32Architecture::I586), Environment::Gnu) => { + "i586-w64-mingw32-" + } + // MinGW supports ARM64 only with an LLVM-based toolchain + // (x86 users might also be using LLVM, but we can't tell that from the Rust target...) + (Architecture::Aarch64(_), Environment::Gnu) => "llvm-", + + // LLVM-based MinGW targets (gnullvm) always use llvm- prefix + (_, Environment::GnuLlvm) => "llvm-", + // fail safe + _ => { + tracing::warn!("Unknown Windows target used for cross-compilation - invoking unprefixed windres"); + "" + } + } + } else { + "" + }; + + // this could probably be done with variables in Dioxus.toml instead of env::var + let windres_path = if let Ok(windres) = env::var("WINDRES") { + windres + } else { + format!("{}windres", prefix) + }; + let ar_path = if let Ok(ar) = env::var("AR") { + ar + } else { + format!("{}ar", prefix) + }; + + let rc_file = self.write_resource_file(output_dir)?; + + tracing::debug!("Input file: '{}'", rc_file.display()); + let output = output_dir.join("resource.o"); + tracing::debug!("Output object file: '{}'", output.display()); + + tracing::debug!("Selected toolkit path: '{}'", &toolkit_path.display()); + tracing::debug!("Selected windres path: '{:?}'", &windres_path); + tracing::debug!("Selected ar path: '{:?}'", &ar_path); + + let status = process::Command::new(&windres_path) + .current_dir(&toolkit_path) + .arg(format!("{}", rc_file.display())) + .arg(format!("{}", output.display())) + .output()?; + + if !status.status.success() { + return Err(anyhow!("Compiling resource file {:?}", &status.stderr)); + } + + let libname = output_dir.join("libresource.a"); + tracing::debug!("Output lib file: '{}'", output.display()); + let status = process::Command::new(&ar_path) + .current_dir(&toolkit_path) + .arg("rsc") + .arg(format!("{}", libname.display())) + .arg(format!("{}", output.display())) + .output()?; + + if !status.status.success() { + return Err(anyhow!( + "Creating static library for resource file {:?}", + &status.stderr + )); + } + + Ok(()) + } + + fn compile_with_toolkit_msvc(&mut self, target: &Triple, output_dir: &Path) -> Result<()> { + // The path to this could also be provided via Dioxus.toml if someone has the exe in other places + let toolkit = get_sdk(matches!(target.architecture, Architecture::X86_64))?; + + let rc_file = self.write_resource_file(output_dir)?; + + tracing::debug!("Selected toolkit path: '{}'", toolkit.display()); + tracing::debug!("Input file: '{}'", rc_file.display()); + let output = output_dir.join("resource.lib"); + tracing::debug!("Output file: '{}'", output.display()); + let mut command = process::Command::new(&toolkit); + + if self.add_toolkit_include { + let root = win_sdk_include_root(&toolkit); + tracing::debug!("Adding toolkit include: {}", root.display()); + command.arg(format!("/I{}", root.join("um").display())); + command.arg(format!("/I{}", root.join("shared").display())); + } + + let status = command + .arg(format!("/fo{}", output.display())) + .arg(format!("{}", rc_file.display())) + .output()?; + + if !status.status.success() { + return Err(anyhow!("Compiling resource file {:?}", &status.stderr)); + } + + Ok(()) + } +} + +/// Find a Windows SDK +fn get_sdk(is_x64: bool) -> io::Result { + // use the reg command, so we don't need a winapi dependency + let output = process::Command::new("reg") + .arg("query") + .arg(r"HKLM\SOFTWARE\Microsoft\Windows Kits\Installed Roots") + .arg("/reg:32") + .output()?; + + if !output.status.success() { + return Err(io::Error::other(format!( + "Querying the registry failed with error message:\n{}", + String::from_utf8(output.stderr).map_err(|e| io::Error::other(e.to_string()))? + ))); + } + + let lines = String::from_utf8(output.stdout).map_err(|e| io::Error::other(e.to_string()))?; + let mut lines: Vec<&str> = lines.lines().collect(); + lines.reverse(); + for line in lines { + if line.trim().starts_with("KitsRoot") { + let kit: String = line + .chars() + .skip(line.find("REG_SZ").unwrap() + 6) + .skip_while(|c| c.is_whitespace()) + .collect(); + + let p = PathBuf::from(&kit); + let rc = if is_x64 { + p.join(r"bin\x64\rc.exe") + } else { + p.join(r"bin\x86\rc.exe") + }; + + if rc.exists() { + return Ok(rc); + } + + if let Ok(bin) = p.join("bin").read_dir() { + for e in bin.filter_map(|e| e.ok()) { + let p = if is_x64 { + e.path().join(r"x64\rc.exe") + } else { + e.path().join(r"x86\rc.exe") + }; + if p.exists() { + return Ok(p); + } + } + } + } + } + Err(io::Error::other("Can not find Windows SDK")) +} + +/// Escapes `string` so it can be safely embedded between the double-quotes of +/// a Windows resource script (`.rc`) string literal, as consumed by +/// `windres` / `rc.exe`. +/// +/// Used by [`WindowsResource::write_resource_file`] for every quoted value we +/// emit — `VERSIONINFO` `VALUE` entries (`ProductName`, `FileDescription`, +/// etc.) and the path / name-id of each `ICON` resource. Without this, an +/// unescaped `"` or `\` in (for example) a user-supplied `productName` or +/// icon path would produce a malformed `.rc` and a confusing compiler error. +/// +/// The RC string-literal grammar is a hybrid: +/// - `"` is doubled (`""`) — RC's own quoting convention, *not* `\"`. +/// - Backslash escapes follow C conventions: `\\`, `\n`, `\t`, `\r`, `\'`. +/// +/// See the RC reference: +/// +fn escape_string_windows(string: &str) -> String { + let mut escaped = String::new(); + for chr in string.chars() { + match chr { + '"' => escaped.push_str("\"\""), + '\'' => escaped.push_str("\\'"), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\t' => escaped.push_str("\\t"), + '\r' => escaped.push_str("\\r"), + _ => escaped.push(chr), + }; + } + escaped +} + +fn win_sdk_include_root(path: &Path) -> PathBuf { + let mut tools_path = PathBuf::new(); + let mut iter = path.iter(); + while let Some(p) = iter.next() { + if p == "bin" { + let version = iter.next().unwrap(); + tools_path.push("Include"); + if version.to_string_lossy().starts_with("10.") { + tools_path.push(version); + } + break; + } else { + tools_path.push(p); + } + } + + tools_path +} diff --git a/packages/cli/src/cli/bundle.rs b/packages/cli/src/cli/bundle.rs index a37fa5be2..0e64881ea 100644 --- a/packages/cli/src/cli/bundle.rs +++ b/packages/cli/src/cli/bundle.rs @@ -4,7 +4,7 @@ use crate::{ use anyhow::Context; use path_absolutize::Absolutize; use std::path::PathBuf; -use target_lexicon::Triple; +use target_lexicon::{OperatingSystem, Triple}; use super::*; @@ -158,7 +158,8 @@ impl Bundle { _ = std::fs::remove_dir_all(krate.bundle_dir(build.bundle)); let mut name: PathBuf = krate.executable_name().into(); - if cfg!(windows) { + + if build.triple.operating_system == OperatingSystem::Windows { name.set_extension("exe"); } std::fs::create_dir_all(krate.bundle_dir(build.bundle)) diff --git a/packages/cli/src/config/app.rs b/packages/cli/src/config/app.rs index b7248fda0..9cd39fd8a 100644 --- a/packages/cli/src/config/app.rs +++ b/packages/cli/src/config/app.rs @@ -4,6 +4,10 @@ use std::path::PathBuf; #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)] pub(crate) struct ApplicationConfig { + /// Display name of the application + #[serde(default)] + pub(crate) name: Option, + /// The path where global assets will be added when components are added with `dx components add` #[serde(default)] pub(crate) asset_dir: Option, diff --git a/packages/cli/src/config/bundle.rs b/packages/cli/src/config/bundle.rs index 3bff37ea4..f58f279e4 100644 --- a/packages/cli/src/config/bundle.rs +++ b/packages/cli/src/config/bundle.rs @@ -31,6 +31,14 @@ pub(crate) struct BundleConfig { pub(crate) windows: Option, #[serde(default)] pub(crate) android: Option, + #[serde(default)] + pub(crate) version: Option, + #[serde(default)] + pub(crate) file_version: Option, + #[serde(default)] + pub(crate) original_file_name: Option, + #[serde(default)] + pub(crate) trademark: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] @@ -225,6 +233,38 @@ pub(crate) struct WindowsSettings { /// need to use another tool like `osslsigncode`. #[serde(default)] pub sign_command: Option, + + /// Free-form developer comments embedded in the executable's VERSIONINFO, + /// surfaced in Windows Explorer → Properties → Details → Comments. + #[serde(default)] + pub(crate) comments: Option, + + /// Internal name embedded in the executable's VERSIONINFO. Most apps leave + /// this equal to the product name; override only if the binary is known + /// internally by a different identifier (e.g. an original codename). + #[serde(default)] + pub(crate) internal_name: Option, + + /// VERSIONINFO LANGID for the resource block. Hex form like `0x0409` + /// (en-US) or `0x0407` (de-DE). Defaults to `0x0000` (neutral). See + /// + /// for the full table. + #[serde(default)] + pub(crate) language: Option, + + /// Pass the host Windows SDK's `um/` and `shared/` headers to `rc.exe` via + /// `/I` flags. Only relevant when cross-compiling to MSVC from a + /// non-Windows host where the SDK headers aren't on the default include + /// path. Has no effect for the GNU (`windres`) toolchain. + #[serde(default)] + pub(crate) add_toolkit_include: Option, + + /// Raw `.rc` snippet appended verbatim to the generated resource file. + /// Use this to add custom resources not covered by other settings — + /// application manifests, custom string tables, accelerators, etc. + /// Contents are not escaped. + #[serde(default)] + pub(crate) extra_rc: Option, } #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/packages/cli/src/config/dioxus_config.rs b/packages/cli/src/config/dioxus_config.rs index eaf1d8bc4..33f6d13e6 100644 --- a/packages/cli/src/config/dioxus_config.rs +++ b/packages/cli/src/config/dioxus_config.rs @@ -102,6 +102,7 @@ impl Default for DioxusConfig { fn default() -> Self { Self { application: ApplicationConfig { + name: None, asset_dir: None, out_dir: None, public_dir: Some("public".into()), diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 5aa36ab19..5385075e9 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -21,6 +21,8 @@ dioxus-asset-resolver = { workspace = true, features = ["native"] } generational-box = { workspace = true } dioxus-devtools = { workspace = true, optional = true } +anyhow = { workspace = true } +image = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/packages/desktop/src/default_icon.rs b/packages/desktop/src/default_icon.rs new file mode 100644 index 000000000..77941cff9 --- /dev/null +++ b/packages/desktop/src/default_icon.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use image::load_from_memory; +use image::GenericImageView; +use image::ImageReader; +use std::path::Path; + +/// Pre-decoded RGBA bytes of the bundled fallback icon. +const FALLBACK_ICON_RGBA: &[u8] = include_bytes!("./assets/default_icon.bin"); +const FALLBACK_ICON_WIDTH: u32 = 460; +const FALLBACK_ICON_HEIGHT: u32 = 460; + +/// Trait that creates icons for various types +pub trait DioxusIconTrait { + fn get_icon() -> Result + where + Self: Sized; + fn from_memory(value: &[u8]) -> Result + where + Self: Sized; + fn path>(path: P, size: Option<(u32, u32)>) -> Result + where + Self: Sized; +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use crate::trayicon::DioxusTrayIcon; + +fn load_image_from_memory(value: &[u8]) -> Result<(Vec, u32, u32)> { + let img = load_from_memory(value)?; + let rgba = img.to_rgba8(); + let (width, height) = img.dimensions(); + Ok((rgba.to_vec(), width, height)) +} + +fn load_image_from_path>(path: P) -> Result<(Vec, u32, u32)> { + let img = ImageReader::open(path)?.decode()?; + let rgba = img.to_rgba8(); + let (width, height) = img.dimensions(); + Ok((rgba.to_vec(), width, height)) +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +impl DioxusIconTrait for DioxusTrayIcon { + fn get_icon() -> Result + where + Self: Sized, + { + #[cfg(target_os = "windows")] + if let Ok(icon) = DioxusTrayIcon::from_resource(32512, None) { + return Ok(icon); + } + DioxusTrayIcon::from_rgba( + FALLBACK_ICON_RGBA.to_vec(), + FALLBACK_ICON_WIDTH, + FALLBACK_ICON_HEIGHT, + ) + .map_err(Into::into) + } + + fn from_memory(value: &[u8]) -> Result + where + Self: Sized, + { + let (icon, width, height) = load_image_from_memory(value)?; + DioxusTrayIcon::from_rgba(icon, width, height).map_err(Into::into) + } + + fn path>(path: P, size: Option<(u32, u32)>) -> Result + where + Self: Sized, + { + let (img, width, height) = load_image_from_path(path)?; + if let Some((width, height)) = size { + Ok(DioxusTrayIcon::from_rgba(img, width, height)?) + } else { + Ok(DioxusTrayIcon::from_rgba(img, width, height)?) + } + } +} + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +use crate::menubar::DioxusMenuIcon; + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +impl DioxusIconTrait for DioxusMenuIcon { + fn get_icon() -> Result + where + Self: Sized, + { + #[cfg(target_os = "windows")] + if let Ok(icon) = DioxusMenuIcon::from_resource(32512, None) { + return Ok(icon); + } + DioxusMenuIcon::from_rgba( + FALLBACK_ICON_RGBA.to_vec(), + FALLBACK_ICON_WIDTH, + FALLBACK_ICON_HEIGHT, + ) + .map_err(Into::into) + } + + fn from_memory(value: &[u8]) -> Result + where + Self: Sized, + { + let (icon, width, height) = load_image_from_memory(value)?; + DioxusMenuIcon::from_rgba(icon, width, height).map_err(Into::into) + } + + fn path>(path: P, size: Option<(u32, u32)>) -> Result + where + Self: Sized, + { + let (img, width, height) = load_image_from_path(path)?; + if let Some((width, height)) = size { + Ok(DioxusMenuIcon::from_rgba(img, width, height)?) + } else { + Ok(DioxusMenuIcon::from_rgba(img, width, height)?) + } + } +} + +use tao::window::Icon; + +#[cfg(target_os = "windows")] +use tao::platform::windows::IconExtWindows; + +impl DioxusIconTrait for Icon { + fn get_icon() -> Result + where + Self: Sized, + { + #[cfg(target_os = "windows")] + if let Ok(icon) = Icon::from_resource(32512, None) { + return Ok(icon); + } + Icon::from_rgba( + FALLBACK_ICON_RGBA.to_vec(), + FALLBACK_ICON_WIDTH, + FALLBACK_ICON_HEIGHT, + ) + .map_err(Into::into) + } + + fn from_memory(value: &[u8]) -> Result + where + Self: Sized, + { + let (icon, width, height) = load_image_from_memory(value)?; + Icon::from_rgba(icon, width, height).map_err(Into::into) + } + + fn path>(path: P, size: Option<(u32, u32)>) -> Result + where + Self: Sized, + { + let (img, width, height) = load_image_from_path(path)?; + if let Some((width, height)) = size { + Ok(Icon::from_rgba(img, width, height)?) + } else { + Ok(Icon::from_rgba(img, width, height)?) + } + } +} + +/// Provides the default icon of the app. +/// +/// On Windows this prefers the icon embedded as resource id `IDI::APPLICATION` +/// (32512) by `dx`'s bundler, falling back to a generic Dioxus icon when the +/// resource is missing (e.g. when running with plain `cargo run`). On all +/// other platforms the bundled fallback icon is returned directly. +pub fn default_icon() -> Result { + T::get_icon() +} + +/// Helper function to load image from include_bytes!("image.png") +pub fn icon_from_memory(value: &[u8]) -> Result { + T::from_memory(value) +} + +/// Helper function to load image from path +pub fn icon_from_path>( + path: P, + size: Option<(u32, u32)>, +) -> Result { + T::path(path, size) +} diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index 22b3cb6c8..8d91e5cb7 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -9,6 +9,7 @@ mod android_sync_lock; mod app; mod assets; mod config; +mod default_icon; mod desktop_context; mod document; mod edits; @@ -50,6 +51,7 @@ pub mod trayicon; // Public exports pub use assets::AssetRequest; pub use config::{Config, WindowCloseBehaviour}; +pub use default_icon::{default_icon, icon_from_memory, icon_from_path}; pub use desktop_context::{ window, DesktopContext, DesktopService, PendingDesktopContext, WeakDesktopContext, }; diff --git a/packages/desktop/src/menubar.rs b/packages/desktop/src/menubar.rs index b63d07d17..0817bd6f4 100644 --- a/packages/desktop/src/menubar.rs +++ b/packages/desktop/src/menubar.rs @@ -5,6 +5,11 @@ pub type DioxusMenu = muda::Menu; #[cfg(any(target_os = "ios", target_os = "android"))] pub type DioxusMenu = (); +#[cfg(not(any(target_os = "ios", target_os = "android")))] +pub type DioxusMenuIcon = muda::Icon; +#[cfg(any(target_os = "ios", target_os = "android"))] +pub type DioxusMenuIcon = (); + /// Initializes the menu bar for the window. #[allow(unused)] pub fn init_menu_bar(menu: &DioxusMenu, window: &Window) { diff --git a/packages/desktop/src/trayicon.rs b/packages/desktop/src/trayicon.rs index 8cc65f7d2..41170a98e 100644 --- a/packages/desktop/src/trayicon.rs +++ b/packages/desktop/src/trayicon.rs @@ -28,20 +28,21 @@ pub type DioxusTray = (); pub fn init_tray_icon(menu: DioxusTrayMenu, icon: Option) -> DioxusTray { #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] { - let builder = tray_icon::TrayIconBuilder::new() - .with_menu(Box::new(menu)) - .with_menu_on_left_click(false) - .with_icon(match icon { - Some(value) => value, - None => tray_icon::Icon::from_rgba( - include_bytes!("./assets/default_icon.bin").to_vec(), - 460, - 460, - ) - .expect("image parse failed"), - }); + let icon = icon.map(Ok).unwrap_or_else(crate::default_icon); - provide_context(builder.build().expect("tray icon builder failed")) + let tray = tray_icon::TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false); + + let tray = match icon { + Ok(icon) => tray.with_icon(icon), + Err(err) => { + tracing::trace!("No tray icon: {:?}", err); + tray + } + }; + + provide_context(tray.build().expect("tray icon builder failed")) } } diff --git a/packages/desktop/src/webview.rs b/packages/desktop/src/webview.rs index e1ec136d2..9710721fc 100644 --- a/packages/desktop/src/webview.rs +++ b/packages/desktop/src/webview.rs @@ -234,14 +234,7 @@ impl WebviewInstance { // We assume that if the icon is None in cfg, then the user just didnt set it if cfg.window.window.window_icon.is_none() { - window = window.with_window_icon(Some( - tao::window::Icon::from_rgba( - include_bytes!("./assets/default_icon.bin").to_vec(), - 460, - 460, - ) - .expect("image parse failed"), - )); + window = window.with_window_icon(crate::default_icon().ok()); } let window = Arc::new(window.build(&shared.target).unwrap());