diff --git a/.changes/nsis.md b/.changes/nsis.md new file mode 100644 index 00000000000..2a196dd7485 --- /dev/null +++ b/.changes/nsis.md @@ -0,0 +1,8 @@ +--- +"tauri-bundler": minor +"tauri-utils": minor +"cli.rs": minor +"cli.js": minor +--- + +Add `nsis` bundle target diff --git a/core/config-schema/schema.json b/core/config-schema/schema.json index 7e9f4705dcc..5802ab296eb 100644 --- a/core/config-schema/schema.json +++ b/core/config-schema/schema.json @@ -146,6 +146,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -169,7 +170,8 @@ "dialog": true, "pubkey": "", "windows": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] } }, "windows": [] @@ -282,6 +284,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -427,7 +430,8 @@ "dialog": true, "pubkey": "", "windows": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] } }, "allOf": [ @@ -1018,7 +1022,7 @@ "type": "boolean" }, "targets": { - "description": "The bundle targets, currently supports [\"deb\", \"appimage\", \"msi\", \"app\", \"dmg\", \"updater\"] or \"all\".", + "description": "The bundle targets, currently supports [\"deb\", \"appimage\", \"nsis\", \"msi\", \"app\", \"dmg\", \"updater\"] or \"all\".", "default": "all", "allOf": [ { @@ -1132,6 +1136,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -1200,6 +1205,13 @@ "msi" ] }, + { + "description": "The NSIS bundle (.exe).", + "type": "string", + "enum": [ + "nsis" + ] + }, { "description": "The macOS application bundle (.app).", "type": "string", @@ -1384,6 +1396,17 @@ "type": "null" } ] + }, + "nsis": { + "description": "Configuration for the installer generated with NSIS.", + "anyOf": [ + { + "$ref": "#/definitions/NsisConfig" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -1632,6 +1655,76 @@ }, "additionalProperties": false }, + "NsisConfig": { + "description": "Configuration for the Installer bundle using NSIS.", + "type": "object", + "properties": { + "license": { + "description": "The path to the license file to render on the installer.", + "type": [ + "string", + "null" + ] + }, + "headerImage": { + "description": "The path to a bitmap file to display on the header of installers pages.\n\nThe recommended dimensions are 150px x 57px.", + "type": [ + "string", + "null" + ] + }, + "sidebarImage": { + "description": "The path to a bitmap file for the Welcome page and the Finish page.\n\nThe recommended dimensions are 164px x 314px.", + "type": [ + "string", + "null" + ] + }, + "installerIcon": { + "description": "The path to an icon file used as the installer icon.", + "type": [ + "string", + "null" + ] + }, + "installMode": { + "description": "Whether the installation will be for all users or just the current user.", + "default": "currentUser", + "allOf": [ + { + "$ref": "#/definitions/NSISInstallerMode" + } + ] + } + }, + "additionalProperties": false + }, + "NSISInstallerMode": { + "description": "Install Modes for the NSIS installer.", + "oneOf": [ + { + "description": "Default mode for the installer.\n\nInstall the app by default in a directory that doesn't require Administrator access.\n\nInstaller metadata will be saved under the `HKCU` registry path.", + "type": "string", + "enum": [ + "currentUser" + ] + }, + { + "description": "Install the app by default in the `Program Files` folder directory requires Administrator access for the installation.\n\nInstaller metadata will be saved under the `HKLM` registry path.", + "type": "string", + "enum": [ + "perMachine" + ] + }, + { + "description": "Combines both modes and allows the user to choose at install time whether to install for the current user or per machine. Note that this mode will require Administrator access even if the user wants to install it for the current user only.\n\nInstaller metadata will be saved under the `HKLM` or `HKCU` registry path based on the user's choice.", + "type": "string", + "enum": [ + "both" + ] + } + ] + }, "AllowlistConfig": { "description": "Allowlist configuration.", "type": "object", @@ -2579,7 +2672,8 @@ "windows": { "description": "The Windows configuration for the updater.", "default": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] }, "allOf": [ { @@ -2599,6 +2693,14 @@ "description": "The updater configuration for Windows.", "type": "object", "properties": { + "installerArgs": { + "description": "Additional arguments given to the NSIS or WiX installer.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "installMode": { "description": "The installation mode for the update on Windows. Defaults to `passive`.", "default": "passive", @@ -2622,7 +2724,7 @@ ] }, { - "description": "The quiet mode means there's no user interaction required. Requires admin privileges if the installer does.", + "description": "The quiet mode means there's no user interaction required. Requires admin privileges if the installer does (WiX).", "type": "string", "enum": [ "quiet" diff --git a/core/tauri-utils/src/config.rs b/core/tauri-utils/src/config.rs index a37056211a6..34a1d018208 100644 --- a/core/tauri-utils/src/config.rs +++ b/core/tauri-utils/src/config.rs @@ -78,6 +78,8 @@ pub enum BundleType { AppImage, /// The Microsoft Installer bundle (.msi). Msi, + /// The NSIS bundle (.exe). + Nsis, /// The macOS application bundle (.app). App, /// The Apple Disk Image bundle (.dmg). @@ -95,6 +97,7 @@ impl Display for BundleType { Self::Deb => "deb", Self::AppImage => "appimage", Self::Msi => "msi", + Self::Nsis => "nsis", Self::App => "app", Self::Dmg => "dmg", Self::Updater => "updater", @@ -122,6 +125,7 @@ impl<'de> Deserialize<'de> for BundleType { "deb" => Ok(Self::Deb), "appimage" => Ok(Self::AppImage), "msi" => Ok(Self::Msi), + "nsis" => Ok(Self::Nsis), "app" => Ok(Self::App), "dmg" => Ok(Self::Dmg), "updater" => Ok(Self::Updater), @@ -416,6 +420,58 @@ pub struct WixConfig { pub dialog_image_path: Option, } +/// Configuration for the Installer bundle using NSIS. +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NsisConfig { + /// The path to the license file to render on the installer. + pub license: Option, + /// The path to a bitmap file to display on the header of installers pages. + /// + /// The recommended dimensions are 150px x 57px. + pub header_image: Option, + /// The path to a bitmap file for the Welcome page and the Finish page. + /// + /// The recommended dimensions are 164px x 314px. + pub sidebar_image: Option, + /// The path to an icon file used as the installer icon. + pub installer_icon: Option, + /// Whether the installation will be for all users or just the current user. + #[serde(default)] + pub install_mode: NSISInstallerMode, +} + +/// Install Modes for the NSIS installer. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +pub enum NSISInstallerMode { + /// Default mode for the installer. + /// + /// Install the app by default in a directory that doesn't require Administrator access. + /// + /// Installer metadata will be saved under the `HKCU` registry path. + CurrentUser, + /// Install the app by default in the `Program Files` folder directory requires Administrator + /// access for the installation. + /// + /// Installer metadata will be saved under the `HKLM` registry path. + PerMachine, + /// Combines both modes and allows the user to choose at install time + /// whether to install for the current user or per machine. Note that this mode + /// will require Administrator access even if the user wants to install it for the current user only. + /// + /// Installer metadata will be saved under the `HKLM` or `HKCU` registry path based on the user's choice. + Both, +} + +impl Default for NSISInstallerMode { + fn default() -> Self { + Self::CurrentUser + } +} + /// Install modes for the Webview2 runtime. /// Note that for the updater bundle [`Self::DownloadBootstrapper`] is used. /// @@ -512,6 +568,8 @@ pub struct WindowsConfig { pub allow_downgrades: bool, /// Configuration for the MSI generated with WiX. pub wix: Option, + /// Configuration for the installer generated with NSIS. + pub nsis: Option, } impl Default for WindowsConfig { @@ -525,6 +583,7 @@ impl Default for WindowsConfig { webview_fixed_runtime_path: None, allow_downgrades: default_allow_downgrades(), wix: None, + nsis: None, } } } @@ -542,7 +601,7 @@ pub struct BundleConfig { /// Whether Tauri should bundle your application or just output the executable. #[serde(default)] pub active: bool, - /// The bundle targets, currently supports ["deb", "appimage", "msi", "app", "dmg", "updater"] or "all". + /// The bundle targets, currently supports ["deb", "appimage", "nsis", "msi", "app", "dmg", "updater"] or "all". #[serde(default)] pub targets: BundleTarget, /// The application identifier in reverse domain name notation (e.g. `com.tauri.example`). @@ -2306,7 +2365,7 @@ pub enum WindowsUpdateInstallMode { /// Specifies there's a basic UI during the installation process, including a final dialog box at the end. BasicUi, /// The quiet mode means there's no user interaction required. - /// Requires admin privileges if the installer does. + /// Requires admin privileges if the installer does (WiX). Quiet, /// Specifies unattended mode, which means the installation only shows a progress bar. Passive, @@ -2377,6 +2436,9 @@ impl<'de> Deserialize<'de> for WindowsUpdateInstallMode { #[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdaterWindowsConfig { + /// Additional arguments given to the NSIS or WiX installer. + #[serde(default, alias = "installer-args")] + pub installer_args: Vec, /// The installation mode for the update on Windows. Defaults to `passive`. #[serde(default, alias = "install-mode")] pub install_mode: WindowsUpdateInstallMode, @@ -3355,7 +3417,8 @@ mod build { impl ToTokens for UpdaterWindowsConfig { fn to_tokens(&self, tokens: &mut TokenStream) { let install_mode = &self.install_mode; - literal_struct!(tokens, UpdaterWindowsConfig, install_mode); + let installer_args = vec_lit(&self.installer_args, str_lit); + literal_struct!(tokens, UpdaterWindowsConfig, install_mode, installer_args); } } diff --git a/core/tauri/src/updater/core.rs b/core/tauri/src/updater/core.rs index e263ccd7c63..133eb38e7e9 100644 --- a/core/tauri/src/updater/core.rs +++ b/core/tauri/src/updater/core.rs @@ -612,15 +612,7 @@ impl Update { archive_buffer, &self.extract_path, self.with_elevated_task, - self - .app - .config() - .tauri - .updater - .windows - .install_mode - .clone() - .msiexec_args(), + &self.app.config(), )?; #[cfg(not(target_os = "windows"))] copy_files_and_run(archive_buffer, &self.extract_path)?; @@ -698,17 +690,19 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> } // Windows - +// // ### Expected structure: // ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler // │ └──[AppName]_[version]_x64.msi # Application MSI +// ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler +// │ └──[AppName]_[version]_x64-setup.exe # NSIS installer // └── ... - +// // ## MSI // Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*) // To replace current version of the application. In later version we'll offer // incremental update to push specific binaries. - +// // ## EXE // Update server can provide a custom EXE (installer) who can run any task. #[cfg(target_os = "windows")] @@ -717,7 +711,7 @@ fn copy_files_and_run( archive_buffer: R, _extract_path: &Path, with_elevated_task: bool, - msiexec_args: &[&str], + config: &crate::Config, ) -> Result { // FIXME: We need to create a memory buffer with the MSI and then run it. // (instead of extracting the MSI to a temp path) @@ -736,8 +730,6 @@ fn copy_files_and_run( extractor.extract_into(&tmp_dir)?; let paths = read_dir(&tmp_dir)?; - // This consumes the TempDir without deleting directory on the filesystem, - // meaning that the directory will no longer be automatically deleted. for path in paths { let found_path = path?.path(); @@ -745,9 +737,15 @@ fn copy_files_and_run( // If it's an `exe` we expect an installer not a runtime. if found_path.extension() == Some(OsStr::new("exe")) { // Run the EXE - Command::new(found_path) - .spawn() - .expect("installer failed to start"); + let mut installer = Command::new(found_path); + if crate::utils::config::WindowsUpdateInstallMode::Quiet + == config.tauri.updater.windows.install_mode + { + installer.arg("/S"); + } + installer.args(&config.tauri.updater.windows.installer_args); + + installer.spawn().expect("installer failed to start"); exit(0); } else if found_path.extension() == Some(OsStr::new("msi")) { @@ -801,6 +799,18 @@ fn copy_files_and_run( msi_path_arg.push(&found_path); msi_path_arg.push("\"\"\""); + let mut msiexec_args = config + .tauri + .updater + .windows + .install_mode + .clone() + .msiexec_args() + .iter() + .map(|p| p.to_string()) + .collect::>(); + msiexec_args.extend(config.tauri.updater.windows.installer_args.clone()); + // run the installer and relaunch the application let system_root = std::env::var("SYSTEMROOT"); let powershell_path = system_root.as_ref().map_or_else( diff --git a/core/tests/app-updater/src/main.rs b/core/tests/app-updater/src/main.rs index f0ffd2a758a..db4a8db02aa 100644 --- a/core/tests/app-updater/src/main.rs +++ b/core/tests/app-updater/src/main.rs @@ -8,6 +8,13 @@ )] fn main() { + let mut context = tauri::generate_context!(); + if std::env::var("TARGET").unwrap_or_default() == "nsis" { + context.config_mut().tauri.updater.windows.installer_args = vec![format!( + "/D={}", + std::env::current_exe().unwrap().parent().unwrap().display() + )]; + } tauri::Builder::default() .setup(|app| { let handle = app.handle(); @@ -28,6 +35,6 @@ fn main() { }); Ok(()) }) - .run(tauri::generate_context!()) + .run(context) .expect("error while running tauri application"); } diff --git a/core/tests/app-updater/tests/update.rs b/core/tests/app-updater/tests/update.rs index 996852bc2d0..9f0ad024692 100644 --- a/core/tests/app-updater/tests/update.rs +++ b/core/tests/app-updater/tests/update.rs @@ -54,26 +54,36 @@ fn get_cli_bin_path(cli_dir: &Path, debug: bool) -> Option { } } -fn build_app(cli_bin_path: &Path, cwd: &Path, config: &Config, bundle_updater: bool) { - let mut command = Command::new(cli_bin_path); +fn build_app( + cli_bin_path: &Path, + cwd: &Path, + config: &Config, + bundle_updater: bool, + target: BundleTarget, +) { + let mut command = Command::new(&cli_bin_path); command .args(["build", "--debug", "--verbose"]) .arg("--config") .arg(serde_json::to_string(config).unwrap()) .current_dir(cwd); - #[cfg(windows)] - command.args(["--bundles", "msi"]); #[cfg(target_os = "linux")] - command.args(["--bundles", "appimage"]); + command.args(["--bundles", target.name()]); #[cfg(target_os = "macos")] - command.args(["--bundles", "app"]); + command.args(["--bundles", target.name()]); if bundle_updater { + #[cfg(windows)] + command.args(["--bundles", "msi", "nsis"]); + command .env("TAURI_PRIVATE_KEY", UPDATER_PRIVATE_KEY) .env("TAURI_KEY_PASSWORD", "") .args(["--bundles", "updater"]); + } else { + #[cfg(windows)] + command.args(["--bundles", target.name()]); } let status = command @@ -85,29 +95,83 @@ fn build_app(cli_bin_path: &Path, cwd: &Path, config: &Config, bundle_updater: b } } +#[derive(Copy, Clone)] +enum BundleTarget { + AppImage, + + App, + + Msi, + Nsis, +} + +impl BundleTarget { + fn name(self) -> &'static str { + match self { + Self::AppImage => "appimage", + Self::App => "app", + Self::Msi => "msi", + Self::Nsis => "nsis", + } + } +} + +impl Default for BundleTarget { + fn default() -> Self { + #[cfg(any(target_os = "macos", target_os = "ios"))] + return Self::App; + #[cfg(target_os = "linux")] + return Self::App; + #[cfg(windows)] + return Self::Nsis; + } +} + #[cfg(target_os = "linux")] -fn bundle_path(root_dir: &Path, version: &str) -> PathBuf { - root_dir.join(format!( - "target/debug/bundle/appimage/app-updater_{version}_amd64.AppImage" - )) +fn bundle_paths(root_dir: &Path, version: &str) -> Vec<(BundleTarget, PathBuf)> { + vec![( + BundleTarget::AppImage, + root_dir.join(format!( + "target/debug/bundle/appimage/app-updater_{}_amd64.AppImage", + version + )), + )] } #[cfg(target_os = "macos")] -fn bundle_path(root_dir: &Path, _version: &str) -> PathBuf { - root_dir.join(format!("target/debug/bundle/macos/app-updater.app")) +fn bundle_paths(root_dir: &Path, _version: &str) -> Vec<(BundleTarget, PathBuf)> { + vec![( + BundleTarget::App, + root_dir.join(format!("target/debug/bundle/macos/app-updater.app")), + )] } #[cfg(target_os = "ios")] -fn bundle_path(root_dir: &Path, _version: &str) -> PathBuf { - root_dir.join(format!("target/debug/bundle/ios/app-updater.app")) +fn bundle_paths(root_dir: &Path, _version: &str) -> Vec<(BundleTarget, PathBuf)> { + vec![( + BundleTarget::App, + root_dir.join(format!("target/debug/bundle/ios/app-updater.app")), + )] } #[cfg(windows)] -fn bundle_path(root_dir: &Path, version: &str) -> PathBuf { - root_dir.join(format!( - "target/debug/bundle/msi/app-updater_{}_x64_en-US.msi", - version - )) +fn bundle_paths(root_dir: &Path, version: &str) -> Vec<(BundleTarget, PathBuf)> { + vec![ + ( + BundleTarget::Nsis, + root_dir.join(format!( + "target/debug/bundle/nsis/app-updater_{}_x64-setup.exe", + version + )), + ), + ( + BundleTarget::Msi, + root_dir.join(format!( + "target/debug/bundle/msi/app-updater_{}_x64_en-US.msi", + version + )), + ), + ] } #[test] @@ -139,99 +203,118 @@ fn update_app() { }; // bundle app update - build_app(&cli_bin_path, &manifest_dir, &config, true); - - let updater_ext = if cfg!(windows) { "zip" } else { "tar.gz" }; - - let out_bundle_path = bundle_path(&root_dir, "1.0.0"); - let signature_path = out_bundle_path.with_extension(format!( - "{}.{}.sig", - out_bundle_path.extension().unwrap().to_str().unwrap(), - updater_ext - )); - let signature = std::fs::read_to_string(&signature_path) - .unwrap_or_else(|_| panic!("failed to read signature file {}", signature_path.display())); - let out_updater_path = out_bundle_path.with_extension(format!( - "{}.{}", - out_bundle_path.extension().unwrap().to_str().unwrap(), - updater_ext - )); - let updater_path = root_dir.join(format!( - "target/debug/{}", - out_updater_path.file_name().unwrap().to_str().unwrap() - )); - std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); - - std::thread::spawn(move || { - // start the updater server - let server = tiny_http::Server::http("localhost:3007").expect("failed to start updater server"); - - loop { - if let Ok(request) = server.recv() { - match request.url() { - "/" => { - let mut platforms = HashMap::new(); - - platforms.insert( - target.clone(), - PlatformUpdate { - signature: signature.clone(), - url: "http://localhost:3007/download", - with_elevated_task: false, - }, - ); - let body = serde_json::to_vec(&Update { - version: "1.0.0", - date: time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap(), - platforms, - }) - .unwrap(); - let len = body.len(); - let response = tiny_http::Response::new( - tiny_http::StatusCode(200), - Vec::new(), - std::io::Cursor::new(body), - Some(len), - None, - ); - let _ = request.respond(response); - } - "/download" => { - let _ = request.respond(tiny_http::Response::from_file( - File::open(&updater_path).unwrap_or_else(|_| { - panic!("failed to open updater bundle {}", updater_path.display()) - }), - )); + build_app( + &cli_bin_path, + &manifest_dir, + &config, + true, + Default::default(), + ); + + let updater_zip_ext = if cfg!(windows) { "zip" } else { "tar.gz" }; + + for (bundle_target, out_bundle_path) in bundle_paths(&root_dir, "1.0.0") { + let bundle_updater_ext = out_bundle_path + .extension() + .unwrap() + .to_str() + .unwrap() + .replace("exe", "nsis"); + let signature_path = + out_bundle_path.with_extension(format!("{}.{}.sig", bundle_updater_ext, updater_zip_ext)); + let signature = std::fs::read_to_string(&signature_path) + .unwrap_or_else(|_| panic!("failed to read signature file {}", signature_path.display())); + let out_updater_path = + out_bundle_path.with_extension(format!("{}.{}", bundle_updater_ext, updater_zip_ext)); + let updater_path = root_dir.join(format!( + "target/debug/{}", + out_updater_path.file_name().unwrap().to_str().unwrap() + )); + std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); + + let target = target.clone(); + std::thread::spawn(move || { + // start the updater server + let server = + tiny_http::Server::http("localhost:3007").expect("failed to start updater server"); + + loop { + if let Ok(request) = server.recv() { + match request.url() { + "/" => { + let mut platforms = HashMap::new(); + + platforms.insert( + target.clone(), + PlatformUpdate { + signature: signature.clone(), + url: "http://localhost:3007/download", + with_elevated_task: false, + }, + ); + let body = serde_json::to_vec(&Update { + version: "1.0.0", + date: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + platforms, + }) + .unwrap(); + let len = body.len(); + let response = tiny_http::Response::new( + tiny_http::StatusCode(200), + Vec::new(), + std::io::Cursor::new(body), + Some(len), + None, + ); + let _ = request.respond(response); + } + "/download" => { + let _ = request.respond(tiny_http::Response::from_file( + File::open(&updater_path).unwrap_or_else(|_| { + panic!("failed to open updater bundle {}", updater_path.display()) + }), + )); + // close server + return; + } + _ => (), } - _ => (), } } - } - }); - - config.package.version = "0.1.0"; - - // bundle initial app version - build_app(&cli_bin_path, &manifest_dir, &config, false); - - let mut binary_cmd = if cfg!(windows) { - Command::new(root_dir.join("target/debug/app-updater.exe")) - } else if cfg!(target_os = "macos") { - Command::new(bundle_path(&root_dir, "0.1.0").join("Contents/MacOS/app-updater")) - } else if std::env::var("CI").map(|v| v == "true").unwrap_or_default() { - let mut c = Command::new("xvfb-run"); - c.arg("--auto-servernum") - .arg(bundle_path(&root_dir, "0.1.0")); - c - } else { - Command::new(bundle_path(&root_dir, "0.1.0")) - }; + }); + + config.package.version = "0.1.0"; + + // bundle initial app version + build_app(&cli_bin_path, &manifest_dir, &config, false, bundle_target); + + let mut binary_cmd = if cfg!(windows) { + Command::new(root_dir.join("target/debug/app-updater.exe")) + } else if cfg!(target_os = "macos") { + Command::new( + bundle_paths(&root_dir, "0.1.0") + .first() + .unwrap() + .1 + .join("Contents/MacOS/app-updater"), + ) + } else if std::env::var("CI").map(|v| v == "true").unwrap_or_default() { + let mut c = Command::new("xvfb-run"); + c.arg("--auto-servernum") + .arg(&bundle_paths(&root_dir, "0.1.0").first().unwrap().1); + c + } else { + Command::new(&bundle_paths(&root_dir, "0.1.0").first().unwrap().1) + }; + + binary_cmd.env("TARGET", bundle_target.name()); + + let status = binary_cmd.status().expect("failed to run app"); - let status = binary_cmd.status().expect("failed to run app"); - - if !status.success() { - panic!("failed to run app"); + if !status.success() { + panic!("failed to run app"); + } } } diff --git a/examples/api/src-tauri/src/main.rs b/examples/api/src-tauri/src/main.rs index 1ade16f98e3..542ed35e553 100644 --- a/examples/api/src-tauri/src/main.rs +++ b/examples/api/src-tauri/src/main.rs @@ -1,3 +1,8 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + #[cfg(desktop)] mod desktop; diff --git a/tooling/bundler/Cargo.toml b/tooling/bundler/Cargo.toml index 3f8b07fee29..f3a9211ece4 100644 --- a/tooling/bundler/Cargo.toml +++ b/tooling/bundler/Cargo.toml @@ -39,6 +39,7 @@ uuid = { version = "1", features = [ "v4", "v5" ] } bitness = "0.4" winreg = "0.10" sha2 = "0.10" +sha1 = "0.10" hex = "0.4" glob = "0.3" zip = "0.6" diff --git a/tooling/bundler/src/bundle.rs b/tooling/bundler/src/bundle.rs index bfaf3f7396c..e5ab9bba636 100644 --- a/tooling/bundler/src/bundle.rs +++ b/tooling/bundler/src/bundle.rs @@ -24,7 +24,7 @@ pub use self::{ }, }; use log::{info, warn}; -pub use settings::{WindowsSettings, WixLanguage, WixLanguageConfig, WixSettings}; +pub use settings::{NsisSettings, WindowsSettings, WixLanguage, WixLanguageConfig, WixSettings}; use std::{fmt::Write, path::PathBuf}; @@ -51,6 +51,8 @@ pub fn bundle_project(settings: Settings) -> crate::Result> { PackageType::IosBundle => macos::ios::bundle_project(&settings)?, #[cfg(target_os = "windows")] PackageType::WindowsMsi => windows::msi::bundle_project(&settings, false)?, + #[cfg(target_os = "windows")] + PackageType::Nsis => windows::nsis::bundle_project(&settings, false)?, #[cfg(target_os = "linux")] PackageType::Deb => linux::debian::bundle_project(&settings)?, #[cfg(target_os = "linux")] diff --git a/tooling/bundler/src/bundle/settings.rs b/tooling/bundler/src/bundle/settings.rs index 3aff75adb36..7fbf2d8d8b3 100644 --- a/tooling/bundler/src/bundle/settings.rs +++ b/tooling/bundler/src/bundle/settings.rs @@ -7,7 +7,7 @@ use super::category::AppCategory; use crate::bundle::{common, platform::target_triple}; pub use tauri_utils::config::WebviewInstallMode; use tauri_utils::{ - config::BundleType, + config::{BundleType, NSISInstallerMode}, resources::{external_binaries, ResourcePaths}, }; @@ -26,6 +26,8 @@ pub enum PackageType { IosBundle, /// The Windows bundle (.msi). WindowsMsi, + /// The NSIS bundle (.exe). + Nsis, /// The Linux Debian package bundle (.deb). Deb, /// The Linux RPM bundle (.rpm). @@ -44,6 +46,7 @@ impl From for PackageType { BundleType::Deb => Self::Deb, BundleType::AppImage => Self::AppImage, BundleType::Msi => Self::WindowsMsi, + BundleType::Nsis => Self::Nsis, BundleType::App => Self::MacOsBundle, BundleType::Dmg => Self::Dmg, BundleType::Updater => Self::Updater, @@ -60,6 +63,7 @@ impl PackageType { "deb" => Some(PackageType::Deb), "ios" => Some(PackageType::IosBundle), "msi" => Some(PackageType::WindowsMsi), + "nsis" => Some(PackageType::Nsis), "app" => Some(PackageType::MacOsBundle), "rpm" => Some(PackageType::Rpm), "appimage" => Some(PackageType::AppImage), @@ -76,6 +80,7 @@ impl PackageType { PackageType::Deb => "deb", PackageType::IosBundle => "ios", PackageType::WindowsMsi => "msi", + PackageType::Nsis => "nsis", PackageType::MacOsBundle => "app", PackageType::Rpm => "rpm", PackageType::AppImage => "appimage", @@ -97,6 +102,8 @@ const ALL_PACKAGE_TYPES: &[PackageType] = &[ PackageType::IosBundle, #[cfg(target_os = "windows")] PackageType::WindowsMsi, + #[cfg(target_os = "windows")] + PackageType::Nsis, #[cfg(target_os = "macos")] PackageType::MacOsBundle, #[cfg(target_os = "linux")] @@ -242,6 +249,25 @@ pub struct WixSettings { pub fips_compliant: bool, } +/// Settings specific to the NSIS implementation. +#[derive(Clone, Debug, Default)] +pub struct NsisSettings { + /// The path to the license file to render on the installer. + pub license: Option, + /// The path to a bitmap file to display on the header of installers pages. + /// + /// The recommended dimensions are 150px x 57px. + pub header_image: Option, + /// The path to a bitmap file for the Welcome page and the Finish page. + /// + /// The recommended dimensions are 164px x 314px. + pub sidebar_image: Option, + /// The path to an icon file used as the installer icon. + pub installer_icon: Option, + /// Whether the installation will be for all users or just the current user. + pub install_mode: NSISInstallerMode, +} + /// The Windows bundle settings. #[derive(Clone, Debug)] pub struct WindowsSettings { @@ -256,6 +282,8 @@ pub struct WindowsSettings { pub tsp: bool, /// WiX configuration. pub wix: Option, + /// Nsis configuration. + pub nsis: Option, /// The path to the application icon. Defaults to `./icons/icon.ico`. pub icon_path: PathBuf, /// The installation mode for the Webview2 runtime. @@ -282,6 +310,7 @@ impl Default for WindowsSettings { timestamp_url: None, tsp: false, wix: None, + nsis: None, icon_path: PathBuf::from("icons/icon.ico"), webview_install_mode: Default::default(), webview_fixed_runtime_path: None, @@ -571,7 +600,7 @@ impl Settings { "macos" => vec![PackageType::MacOsBundle, PackageType::Dmg], "ios" => vec![PackageType::IosBundle], "linux" => vec![PackageType::Deb, PackageType::AppImage], - "windows" => vec![PackageType::WindowsMsi], + "windows" => vec![PackageType::WindowsMsi, PackageType::Nsis], os => { return Err(crate::Error::GenericError(format!( "Native {} bundles not yet supported.", diff --git a/tooling/bundler/src/bundle/updater_bundle.rs b/tooling/bundler/src/bundle/updater_bundle.rs index 069efc3d4cc..9601a5deae8 100644 --- a/tooling/bundler/src/bundle/updater_bundle.rs +++ b/tooling/bundler/src/bundle/updater_bundle.rs @@ -11,8 +11,6 @@ use super::macos::app; #[cfg(target_os = "linux")] use super::linux::appimage; -#[cfg(target_os = "windows")] -use super::windows::msi; use log::error; #[cfg(target_os = "windows")] use std::{fs::File, io::prelude::*}; @@ -127,55 +125,84 @@ fn bundle_update(settings: &Settings, bundles: &[Bundle]) -> crate::Result crate::Result> { use crate::bundle::settings::WebviewInstallMode; + use crate::bundle::windows::{msi, nsis}; + use crate::PackageType; + + // find our installers or rebuild + let mut bundle_paths = Vec::new(); + let mut rebuild_installers = || -> crate::Result<()> { + for bundle in bundles { + match bundle.package_type { + PackageType::WindowsMsi => bundle_paths.extend(msi::bundle_project(settings, true)?), + PackageType::Nsis => bundle_paths.extend(nsis::bundle_project(settings, true)?), + _ => {} + }; + } + Ok(()) + }; - // find our .msi or rebuild - let bundle_paths = if matches!( + if matches!( settings.windows().webview_install_mode, WebviewInstallMode::OfflineInstaller { .. } | WebviewInstallMode::EmbedBootstrapper { .. } ) { - msi::bundle_project(settings, true)? + rebuild_installers()?; } else { let paths = bundles .iter() - .find(|bundle| bundle.package_type == crate::PackageType::WindowsMsi) + .filter(|bundle| { + matches!( + bundle.package_type, + PackageType::WindowsMsi | PackageType::Nsis + ) + }) .map(|bundle| bundle.bundle_paths.clone()) - .unwrap_or_default(); + .flatten() + .collect::>(); - // we expect our .msi files to be on `bundle_paths` + // we expect our installer files to be on `bundle_paths` if paths.is_empty() { - msi::bundle_project(settings, false)? + rebuild_installers()?; } else { - paths + bundle_paths.extend(paths); } }; - let mut msi_archived_paths = Vec::new(); - + let mut installers_archived_paths = Vec::new(); for source_path in bundle_paths { // add .zip to our path - let msi_archived_path = source_path - .components() - .fold(PathBuf::new(), |mut p, c| { - if let std::path::Component::Normal(name) = c { - if name == msi::MSI_UPDATER_FOLDER_NAME { - p.push(msi::MSI_FOLDER_NAME); - return p; + let (archived_path, bundle_name) = + source_path + .components() + .fold((PathBuf::new(), String::new()), |(mut p, mut b), c| { + if let std::path::Component::Normal(name) = c { + if let Some(name) = name.to_str() { + // installers bundled for updater should be put in a directory named `${bundle_name}-updater` + if name == msi::UPDATER_OUTPUT_FOLDER_NAME || name == nsis::UPDATER_OUTPUT_FOLDER_NAME + { + b = name.strip_suffix("-updater").unwrap().to_string(); + p.push(&b); + return (p, b); + } + + if name == msi::OUTPUT_FOLDER_NAME || name == nsis::OUTPUT_FOLDER_NAME { + b = name.to_string(); + } + } } - } - p.push(c); - p - }) - .with_extension("msi.zip"); + p.push(c); + (p, b) + }); + let archived_path = archived_path.with_extension(format!("{}.zip", bundle_name)); - info!(action = "Bundling"; "{}", msi_archived_path.display()); + info!(action = "Bundling"; "{}", archived_path.display()); // Create our gzip file - create_zip(&source_path, &msi_archived_path).with_context(|| "Failed to zip update MSI")?; + create_zip(&source_path, &archived_path).with_context(|| "Failed to zip update MSI")?; - msi_archived_paths.push(msi_archived_path); + installers_archived_paths.push(archived_path); } - Ok(msi_archived_paths) + Ok(installers_archived_paths) } #[cfg(target_os = "windows")] diff --git a/tooling/bundler/src/bundle/windows/mod.rs b/tooling/bundler/src/bundle/windows/mod.rs index 386a69f3475..7cf422765c6 100644 --- a/tooling/bundler/src/bundle/windows/mod.rs +++ b/tooling/bundler/src/bundle/windows/mod.rs @@ -4,4 +4,6 @@ // SPDX-License-Identifier: MIT pub mod msi; +pub mod nsis; pub mod sign; +mod util; diff --git a/tooling/bundler/src/bundle/windows/msi.rs b/tooling/bundler/src/bundle/windows/msi.rs index fa008179d7b..6d6d341326e 100644 --- a/tooling/bundler/src/bundle/windows/msi.rs +++ b/tooling/bundler/src/bundle/windows/msi.rs @@ -5,13 +5,13 @@ mod wix; -pub use wix::{MSI_FOLDER_NAME, MSI_UPDATER_FOLDER_NAME}; - use crate::Settings; use log::warn; use std::{self, path::PathBuf}; +pub use wix::{OUTPUT_FOLDER_NAME, UPDATER_OUTPUT_FOLDER_NAME}; + const WIX_REQUIRED_FILES: &[&str] = &[ "candle.exe", "candle.exe.config", diff --git a/tooling/bundler/src/bundle/windows/msi/wix.rs b/tooling/bundler/src/bundle/windows/msi/wix.rs index 3bb2bdab9ab..19e61a6dce5 100644 --- a/tooling/bundler/src/bundle/windows/msi/wix.rs +++ b/tooling/bundler/src/bundle/windows/msi/wix.rs @@ -3,38 +3,37 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use super::super::sign::{sign, SignParams}; use crate::bundle::{ common::CommandExt, path_utils::{copy_file, FileOpts}, settings::Settings, + windows::util::{ + download, download_and_verify, extract_zip, try_sign, validate_version, HashAlgorithm, + WEBVIEW2_BOOTSTRAPPER_URL, WEBVIEW2_X64_INSTALLER_GUID, WEBVIEW2_X86_INSTALLER_GUID, + }, }; -use anyhow::{bail, Context}; +use anyhow::Context; use handlebars::{to_json, Handlebars}; use log::info; use regex::Regex; use serde::{Deserialize, Serialize}; -use sha2::Digest; use std::{ collections::{BTreeMap, HashMap}, fs::{create_dir_all, read_to_string, remove_dir_all, rename, write, File}, - io::{Cursor, Read, Write}, + io::Write, path::{Path, PathBuf}, process::Command, }; use tauri_utils::{config::WebviewInstallMode, resources::resource_relpath}; use uuid::Uuid; -use zip::ZipArchive; + +pub const OUTPUT_FOLDER_NAME: &str = "msi"; +pub const UPDATER_OUTPUT_FOLDER_NAME: &str = "msi-updater"; // URLS for the WIX toolchain. Can be used for cross-platform compilation. pub const WIX_URL: &str = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"; pub const WIX_SHA256: &str = "2c1888d5d1dba377fc7fa14444cf556963747ff9a0a289a3599cf09da03b9e2e"; -pub const MSI_FOLDER_NAME: &str = "msi"; -pub const MSI_UPDATER_FOLDER_NAME: &str = "msi-updater"; -const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; -const WEBVIEW2_X86_INSTALLER_GUID: &str = "a17bde80-b5ab-47b5-8bbb-1cbe93fc6ec9"; -const WEBVIEW2_X64_INSTALLER_GUID: &str = "aa5fd9b3-dc11-4cbc-8343-a50f57b311e1"; // For Cross Platform Compilation. @@ -174,30 +173,6 @@ fn copy_icon(settings: &Settings, filename: &str, path: &Path) -> crate::Result< Ok(icon_target_path) } -fn download(url: &str) -> crate::Result> { - info!(action = "Downloading"; "{}", url); - let response = attohttpc::get(url).send()?; - response.bytes().map_err(Into::into) -} - -/// Function used to download Wix. Checks SHA256 to verify the download. -fn download_and_verify(url: &str, hash: &str) -> crate::Result> { - let data = download(url)?; - info!("validating hash"); - - let mut hasher = sha2::Sha256::new(); - hasher.update(&data); - - let url_hash = hasher.finalize().to_vec(); - let expected_hash = hex::decode(hash)?; - - if expected_hash == url_hash { - Ok(data) - } else { - Err(crate::Error::HashError) - } -} - /// The app installer output path. fn app_installer_output_path( settings: &Settings, @@ -226,39 +201,14 @@ fn app_installer_output_path( Ok(settings.project_out_directory().to_path_buf().join(format!( "bundle/{}/{}.msi", if updater { - MSI_UPDATER_FOLDER_NAME + UPDATER_OUTPUT_FOLDER_NAME } else { - MSI_FOLDER_NAME + OUTPUT_FOLDER_NAME }, package_base_name ))) } -/// Extracts the zips from Wix and VC_REDIST into a useable path. -fn extract_zip(data: &[u8], path: &Path) -> crate::Result<()> { - let cursor = Cursor::new(data); - - let mut zipa = ZipArchive::new(cursor)?; - - for i in 0..zipa.len() { - let mut file = zipa.by_index(i)?; - let dest_path = path.join(file.name()); - let parent = dest_path.parent().expect("Failed to get parent"); - - if !parent.exists() { - create_dir_all(parent)?; - } - - let mut buff: Vec = Vec::new(); - file.read_to_end(&mut buff)?; - let mut fileout = File::create(dest_path).expect("Failed to open file"); - - fileout.write_all(&buff)?; - } - - Ok(()) -} - /// Generates the UUID for the Wix template. fn generate_package_guid(settings: &Settings) -> Uuid { generate_guid(settings.bundle_identifier().as_bytes()) @@ -274,7 +224,7 @@ fn generate_guid(key: &[u8]) -> Uuid { pub fn get_and_extract_wix(path: &Path) -> crate::Result<()> { info!("Verifying wix package"); - let data = download_and_verify(WIX_URL, WIX_SHA256)?; + let data = download_and_verify(WIX_URL, WIX_SHA256, HashAlgorithm::Sha256)?; info!("extracting WIX"); @@ -396,24 +346,6 @@ fn run_light( // Ok(()) // } -fn validate_version(version: &str) -> anyhow::Result<()> { - let version = semver::Version::parse(version).context("invalid app version")?; - if version.major > 255 { - bail!("app version major number cannot be greater than 255"); - } - if version.minor > 255 { - bail!("app version minor number cannot be greater than 255"); - } - if version.patch > 65535 { - bail!("app version patch number cannot be greater than 65535"); - } - if !(version.pre.is_empty() && version.build.is_empty()) { - bail!("app version cannot have build metadata or pre-release identifier"); - } - - Ok(()) -} - // Entry point for bundling and creating the MSI installer. For now the only supported platform is Windows x64. pub fn build_wix_app_installer( settings: &Settings, @@ -442,33 +374,8 @@ pub fn build_wix_app_installer( .find(|bin| bin.main()) .ok_or_else(|| anyhow::anyhow!("Failed to get main binary"))?; let app_exe_source = settings.binary_path(main_binary); - let try_sign = |file_path: &PathBuf| -> crate::Result<()> { - if let Some(certificate_thumbprint) = &settings.windows().certificate_thumbprint { - info!(action = "Signing"; "{}", file_path.display()); - sign( - &file_path, - &SignParams { - product_name: settings.product_name().into(), - digest_algorithm: settings - .windows() - .digest_algorithm - .as_ref() - .map(|algorithm| algorithm.to_string()) - .unwrap_or_else(|| "sha256".to_string()), - certificate_thumbprint: certificate_thumbprint.to_string(), - timestamp_url: settings - .windows() - .timestamp_url - .as_ref() - .map(|url| url.to_string()), - tsp: settings.windows().tsp, - }, - )?; - } - Ok(()) - }; - try_sign(&app_exe_source)?; + try_sign(&app_exe_source, &settings)?; let output_path = settings.project_out_directory().join("wix").join(arch); @@ -549,10 +456,12 @@ pub fn build_wix_app_installer( } else { WEBVIEW2_X86_INSTALLER_GUID }; - let mut offline_installer_path = dirs_next::cache_dir().unwrap(); - offline_installer_path.push("tauri"); - offline_installer_path.push(guid); - offline_installer_path.push(arch); + let offline_installer_path = dirs_next::cache_dir() + .unwrap() + .join("tauri") + .join("Webview2OfflineInstaller") + .join(guid) + .join(arch); create_dir_all(&offline_installer_path)?; let webview2_installer_path = offline_installer_path.join("MicrosoftEdgeWebView2RuntimeInstaller.exe"); @@ -868,7 +777,7 @@ pub fn build_wix_app_installer( &msi_output_path, )?; rename(&msi_output_path, &msi_path)?; - try_sign(&msi_path)?; + try_sign(&msi_path, &settings)?; output_paths.push(msi_path); } diff --git a/tooling/bundler/src/bundle/windows/nsis.rs b/tooling/bundler/src/bundle/windows/nsis.rs new file mode 100644 index 00000000000..a5a8dd66d78 --- /dev/null +++ b/tooling/bundler/src/bundle/windows/nsis.rs @@ -0,0 +1,438 @@ +// Copyright 2019-2021 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::{ + bundle::{ + common::CommandExt, + windows::util::{ + download, download_and_verify, extract_zip, remove_unc_lossy, try_sign, HashAlgorithm, + WEBVIEW2_BOOTSTRAPPER_URL, WEBVIEW2_X64_INSTALLER_GUID, WEBVIEW2_X86_INSTALLER_GUID, + }, + }, + Settings, +}; +use anyhow::Context; +use handlebars::{to_json, Handlebars}; +use log::{info, warn}; +use tauri_utils::{ + config::{NSISInstallerMode, WebviewInstallMode}, + resources::resource_relpath, +}; + +use std::{ + collections::BTreeMap, + fs::{copy, create_dir_all, remove_dir_all, rename, write}, + path::{Path, PathBuf}, + process::Command, +}; + +pub const OUTPUT_FOLDER_NAME: &str = "nsis"; +pub const UPDATER_OUTPUT_FOLDER_NAME: &str = "nsis-updater"; + +// URLS for the NSIS toolchain. +const NSIS_URL: &str = + "https://sourceforge.net/projects/nsis/files/NSIS%203/3.08/nsis-3.08.zip/download"; +const NSIS_SHA1: &str = "057e83c7d82462ec394af76c87d06733605543d4"; +const NSIS_NSCURL_URL: &str = + "https://github.com/tauri-apps/binary-releases/releases/download/nsis-plugins-v0/NScurl-1.2022.6.7.zip"; +const NSIS_APPLICATIONID_URL: &str = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-plugins-v0/NSIS-ApplicationID.zip"; +const NSIS_NSPROCESS_URL: &str = + "https://github.com/tauri-apps/binary-releases/releases/download/nsis-plugins-v0/NsProcess.zip"; +const NSIS_SEMVER_COMPARE: &str = + "https://github.com/tauri-apps/nsis-semvercompare/releases/download/v0.2.0/nsis_semvercompare.dll"; +const NSIS_SEMVER_COMPARE_SHA1: &str = "5A1A233F427C993B7E6A5821761BF5819507B29C"; + +const NSIS_REQUIRED_FILES: &[&str] = &[ + "makensis.exe", + "Bin/makensis.exe", + "Stubs/lzma-x86-unicode", + "Stubs/lzma_solid-x86-unicode", + "Plugins/x86-unicode/NScurl.dll", + "Plugins/x86-unicode/ApplicationID.dll", + "Plugins/x86-unicode/nsProcess.dll", + "Plugins/x86-unicode/nsis_semvercompare.dll", + "Include/MUI2.nsh", + "Include/FileFunc.nsh", + "Include/x64.nsh", +]; + +/// Runs all of the commands to build the NSIS installer. +/// Returns a vector of PathBuf that shows where the NSIS installer was created. +pub fn bundle_project(settings: &Settings, updater: bool) -> crate::Result> { + let tauri_tools_path = dirs_next::cache_dir().unwrap().join("tauri"); + let nsis_toolset_path = tauri_tools_path.join("NSIS"); + + if !nsis_toolset_path.exists() { + get_and_extract_nsis(&nsis_toolset_path, &tauri_tools_path)?; + } else if NSIS_REQUIRED_FILES + .iter() + .any(|p| !nsis_toolset_path.join(p).exists()) + { + warn!("NSIS directory is missing some files. Recreating it."); + std::fs::remove_dir_all(&nsis_toolset_path)?; + get_and_extract_nsis(&nsis_toolset_path, &tauri_tools_path)?; + } + + build_nsis_app_installer(settings, &nsis_toolset_path, &tauri_tools_path, updater) +} + +// Gets NSIS and verifies the download via Sha1 +fn get_and_extract_nsis(nsis_toolset_path: &Path, tauri_tools_path: &Path) -> crate::Result<()> { + info!("Verifying NSIS package"); + + let data = download_and_verify(NSIS_URL, NSIS_SHA1, HashAlgorithm::Sha1)?; + info!("extracting NSIS"); + extract_zip(&data, tauri_tools_path)?; + rename(tauri_tools_path.join("nsis-3.08"), nsis_toolset_path)?; + + let nsis_plugins = nsis_toolset_path.join("Plugins"); + + let data = download(NSIS_NSCURL_URL)?; + info!("extracting NSIS NScurl plugin"); + extract_zip(&data, &nsis_plugins)?; + + let data = download(NSIS_APPLICATIONID_URL)?; + info!("extracting NSIS ApplicationID plugin"); + extract_zip(&data, &nsis_plugins)?; + copy( + nsis_plugins + .join("ReleaseUnicode") + .join("ApplicationID.dll"), + nsis_plugins.join("x86-unicode").join("ApplicationID.dll"), + )?; + + let data = download(NSIS_NSPROCESS_URL)?; + info!("extracting NSIS NsProcess plugin"); + extract_zip(&data, &nsis_plugins)?; + copy( + nsis_plugins.join("Plugin").join("nsProcessW.dll"), + nsis_plugins.join("x86-unicode").join("nsProcess.dll"), + )?; + + let data = download_and_verify( + NSIS_SEMVER_COMPARE, + NSIS_SEMVER_COMPARE_SHA1, + HashAlgorithm::Sha1, + )?; + write( + nsis_plugins + .join("x86-unicode") + .join("nsis_semvercompare.dll"), + data, + )?; + + Ok(()) +} + +fn build_nsis_app_installer( + settings: &Settings, + nsis_toolset_path: &Path, + tauri_tools_path: &Path, + updater: bool, +) -> crate::Result> { + let arch = match settings.binary_arch() { + "x86_64" => "x64", + "x86" => "x86", + target => { + return Err(crate::Error::ArchError(format!( + "unsupported target: {}", + target + ))) + } + }; + + info!("Target: {}", arch); + + let main_binary = settings + .binaries() + .iter() + .find(|bin| bin.main()) + .ok_or_else(|| anyhow::anyhow!("Failed to get main binary"))?; + let app_exe_source = settings.binary_path(main_binary); + + try_sign(&app_exe_source, &settings)?; + + let output_path = settings.project_out_directory().join("nsis").join(arch); + if output_path.exists() { + remove_dir_all(&output_path)?; + } + create_dir_all(&output_path)?; + + let mut data = BTreeMap::new(); + + let bundle_id = settings.bundle_identifier(); + let manufacturer = bundle_id.split('.').nth(1).unwrap_or(bundle_id); + + data.insert("arch", to_json(arch)); + data.insert("bundle_id", to_json(bundle_id)); + data.insert("manufacturer", to_json(manufacturer)); + data.insert("product_name", to_json(settings.product_name())); + + let version = settings.version_string(); + data.insert("version", to_json(&version)); + + data.insert( + "allow_downgrades", + to_json(settings.windows().allow_downgrades), + ); + + let mut install_mode = NSISInstallerMode::CurrentUser; + if let Some(nsis) = &settings.windows().nsis { + install_mode = nsis.install_mode; + if let Some(license) = &nsis.license { + data.insert( + "license", + to_json(remove_unc_lossy(license.canonicalize()?)), + ); + } + if let Some(installer_icon) = &nsis.installer_icon { + data.insert( + "installer_icon", + to_json(remove_unc_lossy(installer_icon.canonicalize()?)), + ); + } + if let Some(header_image) = &nsis.header_image { + data.insert( + "header_image", + to_json(remove_unc_lossy(header_image.canonicalize()?)), + ); + } + if let Some(sidebar_image) = &nsis.sidebar_image { + data.insert( + "sidebar_image", + to_json(remove_unc_lossy(sidebar_image.canonicalize()?)), + ); + } + } + data.insert( + "install_mode", + to_json(match install_mode { + NSISInstallerMode::CurrentUser => "currentUser", + NSISInstallerMode::PerMachine => "perMachine", + NSISInstallerMode::Both => "both", + }), + ); + + let main_binary = settings + .binaries() + .iter() + .find(|bin| bin.main()) + .ok_or_else(|| anyhow::anyhow!("Failed to get main binary"))?; + data.insert( + "main_binary_name", + to_json(main_binary.name().replace(".exe", "")), + ); + data.insert( + "main_binary_path", + to_json(settings.binary_path(main_binary)), + ); + + let out_file = "nsis-output.exe"; + data.insert("out_file", to_json(&out_file)); + + let resources = generate_resource_data(&settings)?; + data.insert("resources", to_json(resources)); + + let binaries = generate_binaries_data(settings)?; + data.insert("binaries", to_json(binaries)); + + let silent_webview2_install = if let WebviewInstallMode::DownloadBootstrapper { silent } + | WebviewInstallMode::EmbedBootstrapper { silent } + | WebviewInstallMode::OfflineInstaller { silent } = + settings.windows().webview_install_mode + { + silent + } else { + true + }; + + let webview2_install_mode = if updater { + WebviewInstallMode::DownloadBootstrapper { + silent: silent_webview2_install, + } + } else { + let mut webview_install_mode = settings.windows().webview_install_mode.clone(); + if let Some(fixed_runtime_path) = settings.windows().webview_fixed_runtime_path.clone() { + webview_install_mode = WebviewInstallMode::FixedRuntime { + path: fixed_runtime_path, + }; + } else if let Some(wix) = &settings.windows().wix { + if wix.skip_webview_install { + webview_install_mode = WebviewInstallMode::Skip; + } + } + webview_install_mode + }; + + let webview2_installer_args = to_json(if silent_webview2_install { + "/silent" + } else { + "" + }); + + data.insert("webview2_installer_args", to_json(webview2_installer_args)); + data.insert( + "install_webview2_mode", + to_json(match webview2_install_mode { + WebviewInstallMode::DownloadBootstrapper { silent: _ } => "downloadBootstrapper", + WebviewInstallMode::EmbedBootstrapper { silent: _ } => "embedBootstrapper", + WebviewInstallMode::OfflineInstaller { silent: _ } => "offlineInstaller", + _ => "", + }), + ); + + match webview2_install_mode { + WebviewInstallMode::EmbedBootstrapper { silent: _ } => { + let webview2_bootstrapper_path = tauri_tools_path.join("MicrosoftEdgeWebview2Setup.exe"); + std::fs::write( + &webview2_bootstrapper_path, + download(WEBVIEW2_BOOTSTRAPPER_URL)?, + )?; + data.insert( + "webview2_bootstrapper_path", + to_json(webview2_bootstrapper_path), + ); + } + WebviewInstallMode::OfflineInstaller { silent: _ } => { + let guid = if arch == "x64" { + WEBVIEW2_X64_INSTALLER_GUID + } else { + WEBVIEW2_X86_INSTALLER_GUID + }; + let offline_installer_path = tauri_tools_path + .join("Webview2OfflineInstaller") + .join(guid) + .join(arch); + create_dir_all(&offline_installer_path)?; + let webview2_installer_path = + offline_installer_path.join("MicrosoftEdgeWebView2RuntimeInstaller.exe"); + if !webview2_installer_path.exists() { + std::fs::write( + &webview2_installer_path, + download( + &format!("https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/{}/MicrosoftEdgeWebView2RuntimeInstaller{}.exe", + guid, + arch.to_uppercase(), + ), + )?, + )?; + } + data.insert("webview2_installer_path", to_json(webview2_installer_path)); + } + _ => {} + } + + let mut handlebars = Handlebars::new(); + handlebars + .register_template_string("installer.nsi", include_str!("./templates/installer.nsi")) + .map_err(|e| e.to_string()) + .expect("Failed to setup handlebar template"); + let installer_nsi_path = output_path.join("installer.nsi"); + write( + &installer_nsi_path, + handlebars.render("installer.nsi", &data)?, + )?; + + let package_base_name = format!( + "{}_{}_{}-setup", + main_binary.name().replace(".exe", ""), + settings.version_string(), + arch, + ); + + let nsis_output_path = output_path.join(out_file); + let nsis_installer_path = settings.project_out_directory().to_path_buf().join(format!( + "bundle/{}/{}.exe", + if updater { + UPDATER_OUTPUT_FOLDER_NAME + } else { + OUTPUT_FOLDER_NAME + }, + package_base_name + )); + create_dir_all(nsis_installer_path.parent().unwrap())?; + + info!(action = "Running"; "makensis.exe to produce {}", nsis_installer_path.display()); + Command::new(nsis_toolset_path.join("makensis.exe")) + .arg("/V4") + .arg(installer_nsi_path) + .current_dir(output_path) + .output_ok() + .context("error running makensis.exe")?; + + rename(&nsis_output_path, &nsis_installer_path)?; + try_sign(&nsis_installer_path, settings)?; + + Ok(vec![nsis_installer_path]) +} + +/// BTreeMap +type ResourcesMap = BTreeMap; +fn generate_resource_data(settings: &Settings) -> crate::Result { + let mut resources = ResourcesMap::new(); + let cwd = std::env::current_dir()?; + + let mut added_resources = Vec::new(); + + for src in settings.resource_files() { + let src = src?; + let resource_path = remove_unc_lossy(cwd.join(&src).canonicalize()?); + + // In some glob resource paths like `assets/**/*` a file might appear twice + // because the `tauri_utils::resources::ResourcePaths` iterator also reads a directory + // when it finds one. So we must check it before processing the file. + if added_resources.contains(&resource_path) { + continue; + } + added_resources.push(resource_path.clone()); + + let target_path = resource_relpath(&src); + resources.insert( + resource_path, + ( + target_path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + target_path, + ), + ); + } + + Ok(resources) +} + +/// BTreeMap +type BinariesMap = BTreeMap; +fn generate_binaries_data(settings: &Settings) -> crate::Result { + let mut binaries = BinariesMap::new(); + let cwd = std::env::current_dir()?; + + for src in settings.external_binaries() { + let src = src?; + let binary_path = remove_unc_lossy(cwd.join(&src).canonicalize()?); + let dest_filename = src + .file_name() + .expect("failed to extract external binary filename") + .to_string_lossy() + .replace(&format!("-{}", settings.target()), ""); + binaries.insert(binary_path, dest_filename); + } + + for bin in settings.binaries() { + if !bin.main() { + let bin_path = settings.binary_path(bin); + binaries.insert( + bin_path.clone(), + bin_path + .file_name() + .expect("failed to extract external binary filename") + .to_string_lossy() + .to_string(), + ); + } + } + + Ok(binaries) +} diff --git a/tooling/bundler/src/bundle/windows/templates/installer.nsi b/tooling/bundler/src/bundle/windows/templates/installer.nsi new file mode 100644 index 00000000000..943baf3a5a3 --- /dev/null +++ b/tooling/bundler/src/bundle/windows/templates/installer.nsi @@ -0,0 +1,478 @@ +Var AppStartMenuFolder +Var ReinstallPageCheck + +!include MUI2.nsh +!include FileFunc.nsh +!include x64.nsh +!include WordFunc.nsh + +!define MANUFACTURER "{{{manufacturer}}}" +!define PRODUCTNAME "{{{product_name}}}" +!define VERSION "{{{version}}}" +!define INSTALLMODE "{{{install_mode}}}" +!define LICENSE "{{{license}}}" +!define INSTALLERICON "{{{installer_icon}}}" +!define SIDEBARIMAGE "{{{sidebar_image}}}" +!define HEADERIMAGE "{{{header_image}}}" +!define MAINBINARYNAME "{{{main_binary_name}}}" +!define MAINBINARYSRCPATH "{{{main_binary_path}}}" +!define BUNDLEID "{{{bundle_id}}}" +!define OUTFILE "{{{out_file}}}" +!define ARCH "{{{arch}}}" +!define ALLOWDOWNGRADES "{{{allow_downgrades}}}" +!define INSTALLWEBVIEW2MODE "{{{install_webview2_mode}}}" +!define WEBVIEW2INSTALLERARGS "{{{webview2_installer_args}}}" +!define WEBVIEW2BOOTSTRAPPERPATH "{{{webview2_bootstrapper_path}}}" +!define WEBVIEW2INSTALLERPATH "{{{webview2_installer_path}}}" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" + +Name "${PRODUCTNAME}" +OutFile "${OUTFILE}" +Unicode true +SetCompressor /SOLID lzma + +!if "${INSTALLMODE}" == "perMachine" + RequestExecutionLevel highest +!endif + +!if "${INSTALLMODE}" == "both" + !define MULTIUSER_MUI + !define MULTIUSER_EXECUTIONLEVEL Highest + !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" + !define MULTIUSER_INSTALLMODE_COMMANDLINE + !if "${ARCH}" == "x64" + !define MULTIUSER_USE_PROGRAMFILES64 + !endif + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" + !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME + !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation + Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "Software\${MANUFACTURER}\${PRODUCTNAME}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 + FunctionEnd + !include MultiUser.nsh +!endif + +!if "${INSTALLERICON}" != "" + !define MUI_ICON "${INSTALLERICON}" +!endif + +!if "${SIDEBARIMAGE}" != "" + !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" +!endif + +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE + !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" +!endif + +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page to create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create desktop shortcut" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut +Function CreateDesktopShortcut + CreateShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ApplicationID::Set "$DESKTOP\${MAINBINARYNAME}.lnk" "${BUNDLEID}" +FunctionEnd +; Show run app after installation. +!define MUI_FINISHPAGE_RUN "$INSTDIR\${MAINBINARYNAME}.exe" + +Function .onInit + !if "${INSTALLMODE}" == "currentUser" + SetShellVarContext current + !else if "${INSTALLMODE}" == "perMachine" + SetShellVarContext all + !endif + + !if "${INSTALLMODE}" == "perMachine" + ; Set default install location + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" + !endif + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd + +; Installer pages, must be ordered as they appear +!insertmacro MUI_PAGE_WELCOME +!if "${LICENSE}" != "" + !insertmacro MUI_PAGE_LICENSE "${LICENSE}" +!endif +!if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_PAGE_INSTALLMODE +!endif +Page custom PageReinstall PageLeaveReinstall +Function PageReinstall + ; Check if there is an existing installation, if not, abort the reinstall page + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} "$R0$R1" == "" ${|} Abort ${|} + + ; Compare this installar version with the existing installation and modify the messages presented to the user accordingly + StrCpy $R4 "older" + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${IfThen} $R0 == "" ${|} StrCpy $R4 "unknown" ${|} + + nsis_semvercompare::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; Reinstalling the same version + ${If} $R0 == 0 + StrCpy $R1 "${PRODUCTNAME} ${VERSION} is already installed. Select the operation you want to perform and click Next to continue." + StrCpy $R2 "Add/Reinstall components" + StrCpy $R3 "Uninstall ${PRODUCTNAME}" + !insertmacro MUI_HEADER_TEXT "Already Installed" "Choose the maintenance option to perform." + StrCpy $R0 "2" + ; Upgrading + ${ElseIf} $R0 == 1 + StrCpy $R1 "An $R4 version of ${PRODUCTNAME} is installed on your system. It's recommended that you uninstall the current version before installing. Select the operation you want to perform and click Next to continue." + StrCpy $R2 "Uninstall before installing" + StrCpy $R3 "Do not uninstall" + !insertmacro MUI_HEADER_TEXT "Already Installed" "Choose how you want to install ${PRODUCTNAME}." + StrCpy $R0 "1" + ; Downgrading + ${ElseIf} $R0 == -1 + StrCpy $R1 "A newer version of ${PRODUCTNAME} is already installed! It is not recommended that you install an older version. If you really want to install this older version, it's better to uninstall the current version first. Select the operation you want to perform and click Next to continue." + StrCpy $R2 "Uninstall before installing" + !if "${ALLOWDOWNGRADES}" == "true" + StrCpy $R3 "Do not uninstall" + !else + StrCpy $R3 "Do not uninstall (Downgrading without uninstall is disabled for this installer)" + !endif + !insertmacro MUI_HEADER_TEXT "Already Installed" "Choose how you want to install ${PRODUCTNAME}." + StrCpy $R0 "1" + ${Else} + Abort + ${EndIf} + + nsDialogs::Create 1018 + Pop $R4 + + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; disable this radio button if downgrades are not allowed + !if "${ALLOWDOWNGRADES}" == "false" + EnableWindow $R3 0 + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ${If} $ReinstallPageCheck != 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 + + nsDialogs::Show +FunctionEnd +Function PageReinstallUpdateSelection + Pop $R1 + + ${NSD_GetState} $R2 $R1 + + ${If} $R1 == ${BST_CHECKED} + StrCpy $ReinstallPageCheck 1 + ${Else} + StrCpy $ReinstallPageCheck 2 + ${EndIf} + +FunctionEnd +Function PageLeaveReinstall + ${NSD_GetState} $R2 $R1 + + ; $R0 holds whether we are reinstalling the same version or not + ; $R0 == "1" -> different versions + ; $R0 == "2" -> same version + ; + ; $R1 holds the radio buttons state. its meaning is dependant on the context + StrCmp $R0 "1" 0 +2 ; Existing install is not the same version? + StrCmp $R1 "1" reinst_uninstall reinst_done + StrCmp $R1 "1" reinst_done ; Same version, skip to add/reinstall components? + + reinst_uninstall: + ReadRegStr $4 SHCTX "Software\${MANUFACTURER}\${PRODUCTNAME}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + + HideWindow + + ClearErrors + ExecWait '$R1 _?=$4' $0 + + BringToFront + + ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code + + ${If} $0 <> 0 + ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + ${If} $0 = 1 ; User aborted uninstaller? + StrCmp $R0 "2" 0 +2 ; Is the existing install the same version? + Quit ; ...yes, already installed, we are done + Abort + ${EndIf} + MessageBox MB_ICONEXCLAMATION "Unable to uninstall!" + Abort + ${Else} + StrCpy $0 $R1 1 + ${IfThen} $0 == '"' ${|} StrCpy $R1 $R1 -1 1 ${|} ; Strip quotes from UninstallString + Delete $R1 + RMDir $INSTDIR + ${EndIf} + + reinst_done: +FunctionEnd +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +; Uninstaller pages +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +;Languages +!insertmacro MUI_LANGUAGE English + +Section EarlyChecks + ; Abort silent installer if downgrades is disabled + !if "${ALLOWDOWNGRADES}" == "false" + IfSilent 0 done + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 != 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "A newer version is already installed! Automatic silent downgrades are disabled for this installer.$\nIt is not recommended that you install an older version. If you really want to install this older version, you have to uninstall the current version first.$\n" + ${EndIf} + Abort + done: + !endif +SectionEnd + +Section Webview2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${EndIf} + ReadRegStr $5 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + + StrCmp $4 "" 0 done + StrCmp $5 "" 0 done + + ;-------------------------------- + ; Webview2 install modes + + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "Downloading Webview2 bootstrapper..." + NScurl::http GET "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" /CANCEL /END + Pop $0 + ${If} $0 == "OK" + DetailPrint "Webview2 bootstrapper downloaded sucessfully" + ${Else} + DetailPrint "Error: Downloading Webview2 Failed - $0" + Abort "Failed to install Webview2. The app can't run without it. Try restarting the installer" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + CreateDirectory "$INSTDIR\redist" + File /oname="$INSTDIR\redist\MicrosoftEdgeWebview2Setup.exe" "WEBVIEW2BOOTSTRAPPERPATH" + DetailPrint "Installing Webview2..." + StrCpy $6 "$INSTDIR\redist\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + CreateDirectory "$INSTDIR\redist" + File /oname="$INSTDIR\redist\MicrosoftEdgeWebView2RuntimeInstaller.exe" "WEBVIEW2INSTALLERPATH" + DetailPrint "Installing Webview2..." + StrCpy $6 "$INSTDIR\redist\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto done + + install_webview2: + DetailPrint "Installing Webview2..." + ; $6 holds the path to the webview2 installer + ExecWait "$6 /install ${WEBVIEW2INSTALLERARGS}" $1 + ${If} $1 == 0 + DetailPrint "Webview2 installed sucessfully" + ${Else} + DetailPrint "Error: Installing Webview2 Failed with exit code $1" + Abort "Failed to install Webview2. The app can't run without it. Try restarting the installer" + ${EndIf} + + done: +SectionEnd + +!macro CheckIfAppIsRunning + nsProcess::_FindProcess "${MAINBINARYNAME}.exe" + Pop $R0 + ${If} $R0 = 0 + IfSilent silent ui + silent: + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 != 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "${PRODUCTNAME} is running. Please close it first then try again.$\n" + ${EndIf} + Abort + ui: + MessageBox MB_OKCANCEL "${PRODUCTNAME} is running$\nClick OK to kill it" IDOK ok IDCANCEL cancel + ok: + nsProcess::_KillProcess "${MAINBINARYNAME}.exe" + Pop $R0 + Sleep 500 + ${If} $R0 = 0 + Goto done + ${Else} + Abort "Failed to kill ${PRODUCTNAME}. Please close it first then try again" + ${EndIf} + cancel: + Abort "${PRODUCTNAME} is running. Please close it first then try again" + ${EndIf} + done: +!macroend + +Section Install + SetOutPath $INSTDIR + + !insertmacro CheckIfAppIsRunning + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources}} + CreateDirectory "$INSTDIR\\{{this.[0]}}" + File /a "/oname={{this.[1]}}" "{{@key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{@key}}" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "Software\${MANUFACTURER}\${PRODUCTNAME}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + + ; Save install mode to be read by the uninstaller in order to remove the correct + ; registry key + FileOpen $4 "$INSTDIR\installmode" w + FileWrite $4 $MultiUser.InstallMode + FileClose $4 + SetFileAttributes "$INSTDIR\installmode" HIDDEN|READONLY + !endif + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ApplicationID::Set "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" "${BUNDLEID}" + !insertmacro MUI_STARTMENU_WRITE_END + +SectionEnd + +Function un.onInit + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif +FunctionEnd + +Section Uninstall + !insertmacro CheckIfAppIsRunning + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + ; Get the saved install mode + FileOpen $4 "$INSTDIR\installmode" r + FileRead $4 $1 + FileClose $4 + Delete "$INSTDIR\installmode" + + ${If} $1 == "AllUsers" + DeleteRegKey HKLM "${UNINSTKEY}" + ${ElseIf} $1 == "CurrentUser" + DeleteRegKey HKCU "${UNINSTKEY}" + ${EndIf} + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + RMDir "$INSTDIR\\{{this.[0]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + RMDir "$INSTDIR" + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + Delete "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + + ; Remove desktop shortcuts + Delete "$DESKTOP\${MAINBINARYNAME}.lnk" +SectionEnd + diff --git a/tooling/bundler/src/bundle/windows/util.rs b/tooling/bundler/src/bundle/windows/util.rs new file mode 100644 index 00000000000..88e7d7cadcc --- /dev/null +++ b/tooling/bundler/src/bundle/windows/util.rs @@ -0,0 +1,148 @@ +// Copyright 2019-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + fs::{create_dir_all, File}, + io::{Cursor, Read, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Context}; +use log::info; +use sha2::Digest; +use zip::ZipArchive; + +pub const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; +pub const WEBVIEW2_X86_INSTALLER_GUID: &str = "a17bde80-b5ab-47b5-8bbb-1cbe93fc6ec9"; +pub const WEBVIEW2_X64_INSTALLER_GUID: &str = "aa5fd9b3-dc11-4cbc-8343-a50f57b311e1"; + +use crate::{ + bundle::windows::sign::{sign, SignParams}, + Settings, +}; + +pub fn download(url: &str) -> crate::Result> { + info!(action = "Downloading"; "{}", url); + let response = attohttpc::get(url).send()?; + response.bytes().map_err(Into::into) +} + +pub enum HashAlgorithm { + Sha256, + Sha1, +} + +/// Function used to download a file and checks SHA256 to verify the download. +pub fn download_and_verify( + url: &str, + hash: &str, + hash_algorithim: HashAlgorithm, +) -> crate::Result> { + let data = download(url)?; + info!("validating hash"); + + match hash_algorithim { + HashAlgorithm::Sha256 => { + let hasher = sha2::Sha256::new(); + verify(&data, hash, hasher)?; + } + HashAlgorithm::Sha1 => { + let hasher = sha1::Sha1::new(); + verify(&data, hash, hasher)?; + } + } + + Ok(data) +} + +fn verify(data: &Vec, hash: &str, mut hasher: impl Digest) -> crate::Result<()> { + hasher.update(data); + + let url_hash = hasher.finalize().to_vec(); + let expected_hash = hex::decode(hash)?; + if expected_hash == url_hash { + Ok(()) + } else { + Err(crate::Error::HashError) + } +} + +pub fn validate_version(version: &str) -> anyhow::Result<()> { + let version = semver::Version::parse(version).context("invalid app version")?; + if version.major > 255 { + bail!("app version major number cannot be greater than 255"); + } + if version.minor > 255 { + bail!("app version minor number cannot be greater than 255"); + } + if version.patch > 65535 { + bail!("app version patch number cannot be greater than 65535"); + } + if !(version.pre.is_empty() && version.build.is_empty()) { + bail!("app version cannot have build metadata or pre-release identifier"); + } + + Ok(()) +} + +pub fn try_sign(file_path: &PathBuf, settings: &Settings) -> crate::Result<()> { + if let Some(certificate_thumbprint) = settings.windows().certificate_thumbprint.as_ref() { + info!(action = "Signing"; "{}", file_path.display()); + sign( + &file_path, + &SignParams { + product_name: settings.product_name().into(), + digest_algorithm: settings + .windows() + .digest_algorithm + .as_ref() + .map(|algorithm| algorithm.to_string()) + .unwrap_or_else(|| "sha256".to_string()), + certificate_thumbprint: certificate_thumbprint.to_string(), + timestamp_url: settings + .windows() + .timestamp_url + .as_ref() + .map(|url| url.to_string()), + tsp: settings.windows().tsp, + }, + )?; + } + Ok(()) +} + +/// Extracts the zips from memory into a useable path. +pub fn extract_zip(data: &[u8], path: &Path) -> crate::Result<()> { + let cursor = Cursor::new(data); + + let mut zipa = ZipArchive::new(cursor)?; + + for i in 0..zipa.len() { + let mut file = zipa.by_index(i)?; + + let dest_path = path.join(file.name()); + if file.is_dir() { + create_dir_all(&dest_path)?; + continue; + } + + let parent = dest_path.parent().expect("Failed to get parent"); + + if !parent.exists() { + create_dir_all(parent)?; + } + + let mut buff: Vec = Vec::new(); + file.read_to_end(&mut buff)?; + let mut fileout = File::create(dest_path).expect("Failed to open file"); + + fileout.write_all(&buff)?; + } + + Ok(()) +} + +pub fn remove_unc_lossy>(p: P) -> PathBuf { + PathBuf::from(p.as_ref().to_string_lossy().replacen(r"\\?\", "", 1)) +} diff --git a/tooling/cli/Cargo.lock b/tooling/cli/Cargo.lock index e71d5e847e8..8af56ed09c7 100644 --- a/tooling/cli/Cargo.lock +++ b/tooling/cli/Cargo.lock @@ -3123,6 +3123,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha1", "sha2", "strsim 0.10.0", "tar", diff --git a/tooling/cli/schema.json b/tooling/cli/schema.json index 7e9f4705dcc..5802ab296eb 100644 --- a/tooling/cli/schema.json +++ b/tooling/cli/schema.json @@ -146,6 +146,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -169,7 +170,8 @@ "dialog": true, "pubkey": "", "windows": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] } }, "windows": [] @@ -282,6 +284,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -427,7 +430,8 @@ "dialog": true, "pubkey": "", "windows": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] } }, "allOf": [ @@ -1018,7 +1022,7 @@ "type": "boolean" }, "targets": { - "description": "The bundle targets, currently supports [\"deb\", \"appimage\", \"msi\", \"app\", \"dmg\", \"updater\"] or \"all\".", + "description": "The bundle targets, currently supports [\"deb\", \"appimage\", \"nsis\", \"msi\", \"app\", \"dmg\", \"updater\"] or \"all\".", "default": "all", "allOf": [ { @@ -1132,6 +1136,7 @@ "allowDowngrades": true, "certificateThumbprint": null, "digestAlgorithm": null, + "nsis": null, "timestampUrl": null, "tsp": false, "webviewFixedRuntimePath": null, @@ -1200,6 +1205,13 @@ "msi" ] }, + { + "description": "The NSIS bundle (.exe).", + "type": "string", + "enum": [ + "nsis" + ] + }, { "description": "The macOS application bundle (.app).", "type": "string", @@ -1384,6 +1396,17 @@ "type": "null" } ] + }, + "nsis": { + "description": "Configuration for the installer generated with NSIS.", + "anyOf": [ + { + "$ref": "#/definitions/NsisConfig" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -1632,6 +1655,76 @@ }, "additionalProperties": false }, + "NsisConfig": { + "description": "Configuration for the Installer bundle using NSIS.", + "type": "object", + "properties": { + "license": { + "description": "The path to the license file to render on the installer.", + "type": [ + "string", + "null" + ] + }, + "headerImage": { + "description": "The path to a bitmap file to display on the header of installers pages.\n\nThe recommended dimensions are 150px x 57px.", + "type": [ + "string", + "null" + ] + }, + "sidebarImage": { + "description": "The path to a bitmap file for the Welcome page and the Finish page.\n\nThe recommended dimensions are 164px x 314px.", + "type": [ + "string", + "null" + ] + }, + "installerIcon": { + "description": "The path to an icon file used as the installer icon.", + "type": [ + "string", + "null" + ] + }, + "installMode": { + "description": "Whether the installation will be for all users or just the current user.", + "default": "currentUser", + "allOf": [ + { + "$ref": "#/definitions/NSISInstallerMode" + } + ] + } + }, + "additionalProperties": false + }, + "NSISInstallerMode": { + "description": "Install Modes for the NSIS installer.", + "oneOf": [ + { + "description": "Default mode for the installer.\n\nInstall the app by default in a directory that doesn't require Administrator access.\n\nInstaller metadata will be saved under the `HKCU` registry path.", + "type": "string", + "enum": [ + "currentUser" + ] + }, + { + "description": "Install the app by default in the `Program Files` folder directory requires Administrator access for the installation.\n\nInstaller metadata will be saved under the `HKLM` registry path.", + "type": "string", + "enum": [ + "perMachine" + ] + }, + { + "description": "Combines both modes and allows the user to choose at install time whether to install for the current user or per machine. Note that this mode will require Administrator access even if the user wants to install it for the current user only.\n\nInstaller metadata will be saved under the `HKLM` or `HKCU` registry path based on the user's choice.", + "type": "string", + "enum": [ + "both" + ] + } + ] + }, "AllowlistConfig": { "description": "Allowlist configuration.", "type": "object", @@ -2579,7 +2672,8 @@ "windows": { "description": "The Windows configuration for the updater.", "default": { - "installMode": "passive" + "installMode": "passive", + "installerArgs": [] }, "allOf": [ { @@ -2599,6 +2693,14 @@ "description": "The updater configuration for Windows.", "type": "object", "properties": { + "installerArgs": { + "description": "Additional arguments given to the NSIS or WiX installer.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "installMode": { "description": "The installation mode for the update on Windows. Defaults to `passive`.", "default": "passive", @@ -2622,7 +2724,7 @@ ] }, { - "description": "The quiet mode means there's no user interaction required. Requires admin privileges if the installer does.", + "description": "The quiet mode means there's no user interaction required. Requires admin privileges if the installer does (WiX).", "type": "string", "enum": [ "quiet" diff --git a/tooling/cli/src/build.rs b/tooling/cli/src/build.rs index 6c25803aea9..1323a65de70 100644 --- a/tooling/cli/src/build.rs +++ b/tooling/cli/src/build.rs @@ -372,7 +372,13 @@ fn print_signed_updater_archive(output_paths: &[PathBuf]) -> crate::Result<()> { let msg = format!("{} {} at:", output_paths.len(), pluralised); info!("{}", msg); for path in output_paths { + #[cfg(unix)] info!(" {}", path.display()); + #[cfg(windows)] + info!( + " {}", + path.display().to_string().replacen(r"\\?\", "", 1) + ); } Ok(()) } diff --git a/tooling/cli/src/helpers/config.rs b/tooling/cli/src/helpers/config.rs index 1fee5462639..a0ebd1eef89 100644 --- a/tooling/cli/src/helpers/config.rs +++ b/tooling/cli/src/helpers/config.rs @@ -97,6 +97,16 @@ pub fn wix_settings(config: WixConfig) -> tauri_bundler::WixSettings { } } +pub fn nsis_settings(config: NsisConfig) -> tauri_bundler::NsisSettings { + tauri_bundler::NsisSettings { + license: config.license, + header_image: config.header_image, + sidebar_image: config.sidebar_image, + installer_icon: config.installer_icon, + install_mode: config.install_mode, + } +} + fn config_handle() -> &'static ConfigHandle { static CONFING_HANDLE: Lazy = Lazy::new(Default::default); &CONFING_HANDLE diff --git a/tooling/cli/src/interface/rust.rs b/tooling/cli/src/interface/rust.rs index 41c6f442a90..a7fd1e4b795 100644 --- a/tooling/cli/src/interface/rust.rs +++ b/tooling/cli/src/interface/rust.rs @@ -36,7 +36,7 @@ use tauri_utils::config::parse::is_configuration_file; use super::{AppSettings, ExitReason, Interface}; use crate::helpers::{ app_paths::{app_dir, tauri_dir}, - config::{reload as reload_config, wix_settings, Config}, + config::{nsis_settings, reload as reload_config, wix_settings, Config}, }; mod cargo_config; @@ -1060,6 +1060,7 @@ fn tauri_config_to_bundle_settings( wix.license = wix.license.map(|l| tauri_dir().join(l)); wix }), + nsis: config.windows.nsis.map(nsis_settings), icon_path: windows_icon_path, webview_install_mode: config.windows.webview_install_mode, webview_fixed_runtime_path: config.windows.webview_fixed_runtime_path,