Custom Windows Installer & Uninstaller (#9815)

This PR introduces a new installer and uninstaller for the Windows platform.

Both are written in Rust and compiled to a single executable. The executable has no dependencies (other than what is included in the Windows), links the C++ runtime statically if needed.

The change is motivated by numerous issues with with the `electron-builder`-generated installers. The new installer should behave better, not have issues with long paths and unblock the `electron-builder` upgrade (which will significantly simplify the workflow definitions).

To build an installer, one needs to provide the unpacked application (generated by `electron-builder`) and the `electron-builder` configuration (with a few minor extensions). Code signing is also supported.
This commit is contained in:
Michał Wawrzyniec Urbańczyk 2024-05-07 18:22:11 +02:00 committed by GitHub
parent ced7ba2de2
commit a4f56e92aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 4068 additions and 345 deletions

View File

@ -4,14 +4,16 @@ rustflags = ["--cfg", "tokio_unstable"]
[target.wasm32-unknown-unknown]
rustflags = [
# Increas the stack size from 1MB to 2MB. This is required to avoid running out of stack space
# in debug builds. The error is reported as `RuntimeError: memory access out of bounds`.
"-C",
"link-args=-z stack-size=2097152",
# Increas the stack size from 1MB to 2MB. This is required to avoid running out of stack space
# in debug builds. The error is reported as `RuntimeError: memory access out of bounds`.
"-C",
"link-args=-z stack-size=2097152",
]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=/STACK:2097152"]
# Static linking is required to avoid the need for the Visual C++ Redistributable. We care about this primarily for our
# installer binary package.
rustflags = ["-C", "link-arg=/STACK:2097152", "-C", "target-feature=+crt-static"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-Wl,--stack,2097152"]

4
.gitignore vendored
View File

@ -117,6 +117,10 @@ project/metals.sbt
/build.json
/app/ide-desktop/lib/client/electron-builder-config.json
# Resources fire generated build-time for Win installer/uninstaller.
/build/install/installer/archive.rc
/build/install/icon.rc
#################
## Build Cache ##

645
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,10 @@ members = [
"build/macros/proc-macro",
"build/ci-gen",
"build/cli",
"build/install",
"build/install/config",
"build/install/installer",
"build/install/uninstaller",
"lib/rust/*",
"lib/rust/parser/doc-parser",
"lib/rust/parser/src/syntax/tree/visitor",
@ -89,23 +93,38 @@ blocks_in_conditions = "allow" # Until the issue is fixed: https://github.com/ru
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# We are tryingto maintain minimum set of dependencies. Before adding a new dependency, consult it
# with the core development team. Thank you!
chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.5.4", features = ["derive", "env", "wrap_help", "string"] }
derive-where = "1.2.7"
directories = { version = "5.0.1" }
dirs = { version = "5.0.1" }
flate2 = { version = "1.0.28" }
indicatif = { version = "0.17.7", features = ["tokio"] }
multimap = { version = "0.9.1" }
native-windows-gui = { version = "1.0.13" }
nix = { version = "0.27.1" }
octocrab = { git = "https://github.com/enso-org/octocrab", default-features = false, features = [
"rustls",
] }
path-absolutize = "3.1.1"
platforms = { version = "3.2.0", features = ["serde"] }
portpicker = { version = "0.1.1" }
regex = { version = "1.6.0" }
serde = { version = "1.0.130", features = ["derive", "rc"] }
serde_yaml = { version = "0.9.16" }
sha2 = { version = "0.10.8" }
sysinfo = { version = "0.30.7" }
tokio = { version = "1.23.0", features = ["full", "tracing"] }
tokio-stream = { version = "0.1.12", features = ["fs"] }
tokio-util = { version = "0.7.4", features = ["full"] }
tar = { version = "0.4.40" }
tokio = { version = "1.37.0", features = ["full", "tracing"] }
tokio-stream = { version = "0.1.15", features = ["fs"] }
tokio-util = { version = "0.7.10", features = ["full"] }
tracing = { version = "0.1.40" }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
walkdir = { version = "2.5.0" }
wasm-bindgen = { version = "0.2.92", features = [] }
wasm-bindgen-test = { version = "0.3.34" }
windows = { version = "0.52.0", features = ["Win32", "Win32_UI", "Win32_UI_Shell", "Win32_System", "Win32_System_LibraryLoader", "Win32_Foundation", "Win32_System_Com"] }
winreg = { version = "0.52.0" }
anyhow = { version = "1.0.66" }
failure = { version = "0.1.8" }
derive_more = { version = "0.99" }

View File

@ -40,6 +40,23 @@ export interface Arguments {
readonly platform: electronBuilder.Platform
}
/** File association configuration, extended with information needed by the `enso-installer`. */
interface ExtendedFileAssociation extends electronBuilder.FileAssociation {
/** The Windows registry key under which the file association is registered.
*
* Should follow the pattern `Enso.CamelCaseName`. */
readonly progId: string
}
/** Additional configuration for the installer. */
interface InstallerAdditionalConfig {
/** The company name to be used in the installer. */
readonly publisher: string
/** File association configuration. */
readonly fileAssociations: ExtendedFileAssociation[]
}
//======================================
// === Argument parser configuration ===
//======================================
@ -90,13 +107,49 @@ export const args: Arguments = await yargs(process.argv.slice(2))
// === Electron builder configuration ===
// ======================================
/** File associations for the IDE. */
export const EXTENDED_FILE_ASSOCIATIONS = [
{
ext: fileAssociations.SOURCE_FILE_EXTENSION,
name: `${common.PRODUCT_NAME} Source File`,
role: 'Editor',
// Note that MIME type is used on Windows by the enso-installer to register the file association.
// This behavior is unlike what electron-builder does.
mimeType: 'text/plain',
progId: 'Enso.Source',
},
{
ext: fileAssociations.BUNDLED_PROJECT_EXTENSION,
name: `${common.PRODUCT_NAME} Project Bundle`,
role: 'Editor',
mimeType: 'application/gzip',
progId: 'Enso.ProjectBundle',
},
]
/** Returns non-extended file associations, as required by the `electron-builder`.
*
* Note that we need to actually remove any additional fields that we added to the file associations,
* as the `electron-builder` will error out if it encounters unknown fields. */
function getFileAssociations(): electronBuilder.FileAssociation[] {
return EXTENDED_FILE_ASSOCIATIONS.map(assoc => {
const { ext, name, role, mimeType } = assoc
return { ext, name, role, mimeType }
})
}
/** Returns additional configuration for the `enso-installer`. */
function getInstallerAdditionalConfig(): InstallerAdditionalConfig {
return {
publisher: common.COMPANY_NAME,
fileAssociations: EXTENDED_FILE_ASSOCIATIONS,
}
}
/** Based on the given arguments, creates a configuration for the Electron Builder. */
export function createElectronBuilderConfig(passedArgs: Arguments): electronBuilder.Configuration {
let version = BUILD_INFO.version
if (
passedArgs.target === 'msi' ||
(passedArgs.target == null && process.platform === 'win32')
) {
if (passedArgs.target === 'msi') {
// MSI installer imposes some restrictions on the version number. Namely, product version must have a major
// version less than 256, a minor version less than 256, and a build version less than 65536.
//
@ -110,6 +163,8 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
productName: common.PRODUCT_NAME,
extraMetadata: {
version,
// This provides extra data for the installer.
installer: getInstallerAdditionalConfig(),
},
copyright: `Copyright © ${new Date().getFullYear()} ${common.COMPANY_NAME}`,
@ -165,7 +220,7 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
win: {
// Compression is not used as the build time is huge and file size saving
// almost zero.
target: passedArgs.target ?? 'nsis',
target: passedArgs.target ?? 'dir',
icon: `${passedArgs.iconsDist}/icon.ico`,
},
linux: {
@ -187,18 +242,7 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
filter: ['!**.tar.gz', '!**.zip'],
},
],
fileAssociations: [
{
ext: fileAssociations.SOURCE_FILE_EXTENSION,
name: `${common.PRODUCT_NAME} Source File`,
role: 'Editor',
},
{
ext: fileAssociations.BUNDLED_PROJECT_EXTENSION,
name: `${common.PRODUCT_NAME} Project Bundle`,
role: 'Editor',
},
],
fileAssociations: getFileAssociations(),
directories: {
output: `${passedArgs.ideDist}`,
},
@ -233,15 +277,15 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
sign: false,
},
afterAllArtifactBuild: computeHashes,
afterPack: ctx => {
afterPack: (context: electronBuilder.AfterPackContext) => {
if (passedArgs.platform === electronBuilder.Platform.MAC) {
// Make the subtree writable, so we can sign the binaries.
// This is needed because GraalVM distribution comes with read-only binaries.
childProcess.execFileSync('chmod', ['-R', 'u+w', ctx.appOutDir])
childProcess.execFileSync('chmod', ['-R', 'u+w', context.appOutDir])
}
},
afterSign: async context => {
afterSign: async (context: electronBuilder.AfterPackContext) => {
// Notarization for macOS.
if (
passedArgs.platform === electronBuilder.Platform.MAC &&
@ -286,6 +330,17 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
}
}
/** Write the configuration to a JSON file.
*
* On Windows it is necessary to provide configuration to our installer. On other platforms, this may be useful for debugging.
*
* The configuration will be extended with additional information needed by the `enso-installer`.
*/
async function dumpConfiguration(configPath: string, config: electronBuilder.Configuration) {
const jsonConfig = JSON.stringify(config)
await fs.writeFile(configPath, jsonConfig)
}
/** Build the IDE package with Electron Builder. */
export async function buildPackage(passedArgs: Arguments) {
// `electron-builder` checks for presence of `node_modules` directory. If it is not present, it
@ -297,10 +352,22 @@ export async function buildPackage(passedArgs: Arguments) {
// failing because of that.
await fs.mkdir('node_modules', { recursive: true })
const config = createElectronBuilderConfig(passedArgs)
const cliOpts: electronBuilder.CliOptions = {
config: createElectronBuilderConfig(passedArgs),
config,
targets: passedArgs.platform.createTarget(),
}
// If `ENSO_BUILD_ELECTRON_BUILDER_CONFIG` is set, we will write the configuration to the
// specified path. Otherwise, we will write it to the default path.
// This is used on Windows to provide the configuration to the installer build. On other
// platforms, this may be useful for debugging.
const configPath =
process.env['ENSO_BUILD_ELECTRON_BUILDER_CONFIG'] ??
`${passedArgs.ideDist}/electron-builder-config.yaml`
console.log(`Writing configuration to ${configPath}`)
await dumpConfiguration(configPath, config)
console.log('Building with configuration:', cliOpts)
const result = await electronBuilder.build(cliOpts)
console.log('Electron Builder is done. Result:', result)

View File

@ -35,6 +35,7 @@
},
"devDependencies": {
"@electron/notarize": "2.1.0",
"@types/node": "^20.10.5",
"electron": "25.7.0",
"electron-builder": "^22.14.13",
"enso-common": "^1.0.0",

View File

@ -6,7 +6,7 @@
// This file is being imported for its types.
// prettier-ignore
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/consistent-type-imports
import * as buildJson from './../../build.json' assert { type: 'json' }
import * as buildJson from './../../build.json' assert {type: 'json'}
// =============
// === Types ===
@ -94,6 +94,7 @@ declare global {
// eslint-disable-next-line no-restricted-syntax
interface ProcessEnv {
readonly [key: string]: never
// These are environment variables, and MUST be in CONSTANT_CASE.
/* eslint-disable @typescript-eslint/naming-convention */
// This is declared in `@types/node`. It MUST be re-declared here to suppress the error
@ -113,6 +114,8 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_BUILD_ICONS?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_BUILD_ELECTRON_BUILDER_CONFIG?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly npm_package_name?: string
// === Cloud environment variables ===

View File

@ -12,7 +12,7 @@ futures = { workspace = true }
serde = "1.0.145"
serde_json = { workspace = true }
serde_yaml = { workspace = true }
tracing = "0.1.36"
tracing = { workspace = true }
[lints]
workspace = true

View File

@ -215,6 +215,26 @@ pub fn copy_file_if_different(source: impl AsRef<Path>, target: impl AsRef<Path>
Ok(())
}
/// Writes the given contents to the specified path only if the new contents differ from the
/// existing ones.
///
/// If the file does not exist, it will be created. If the file exists and its contents are
/// identical to the new contents, the function will not perform a write operation. This is useful
/// for avoiding unnecessary file changes.
pub fn write_if_different(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result {
if let Ok(existing_metadata) = metadata(&path) {
if existing_metadata.len() == contents.as_ref().len() as u64 {
// We allow ? below. If the file exists, it should be readable - or something is wrong.
let existing_contents = read(&path)?;
if existing_contents == contents.as_ref() {
trace!("Contents are identical, not writing to {}.", path.as_ref().display());
return Ok(());
}
}
}
write(&path, contents)
}
/// Append contents to the file.
///
/// If the file does not exist, it will be created.

View File

@ -12,7 +12,7 @@ aws-sdk-s3 = "0.21.0"
base64 = "0.13.0"
bytes = { workspace = true }
byte-unit = { workspace = true }
chrono = { version = "0.4.19", features = ["serde"] }
chrono = { workspace = true }
clap = { workspace = true }
derivative = { workspace = true }
derive_more = { workspace = true }
@ -25,6 +25,7 @@ heck = "0.4.0"
enso-build-base = { path = "../base" }
enso-enso-font = { path = "../../lib/rust/enso-font" }
enso-font = { path = "../../lib/rust/font" }
enso-install-config = { path = "../install/config" }
ide-ci = { path = "../ci_utils" }
mime = "0.3.16"
new_mime_guess = "4.0.1"
@ -39,15 +40,16 @@ serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
scopeguard = "1.1.0"
sha2 = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
tempfile = "3.2.0"
toml = "0.5.8"
tokio = { workspace = true }
tracing = { version = "0.1.37" }
tracing = { workspace = true }
url = "2.2.2"
uuid = { version = "1.1.0", features = ["v4"] }
walkdir = "2.3.2"
walkdir = { workspace = true }
zip = { version = "0.6.2", default-features = false, features = ["deflate"] }
[build-dependencies]

View File

@ -3,10 +3,12 @@
use ide_ci::prelude::*;
use ide_ci::programs::cargo::build::rerun_if_file_changed;
fn main() -> Result {
println!("cargo:rerun-if-changed=paths.yaml");
rerun_if_file_changed("paths.yaml");
let yaml_contents = include_bytes!("paths.yaml");
let code = enso_build_macros_lib::paths::process(yaml_contents.as_slice())?;
let out_dir = ide_ci::programs::cargo::build_env::OUT_DIR.get()?;

View File

@ -108,6 +108,7 @@
enso-pack/:
dist/: # Here ensogl-pack outputs its artifacts
generated-java/:
rust/:
test-results/:
test/:
Benchmarks/:

View File

@ -43,7 +43,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test_name() -> Result {
setup_logging()?;
setup_logging().ok();
let tag = "test_runtime_image";
info!("Current directory: {}", ide_ci::env::current_dir()?.display());
let root = deduce_repository_path()?;

View File

@ -389,6 +389,10 @@ pub fn expose_os_specific_signing_secret(os: OS, step: Step) -> Step {
&crate::ide::web::env::APPLETEAMID,
)
.with_env(crate::ide::web::env::CSC_IDENTITY_AUTO_DISCOVERY, "true")
// `CSC_FOR_PULL_REQUEST` can potentially expose sensitive information to third-party,
// see the comment in the definition of `CSC_FOR_PULL_REQUEST` for more information.
//
// In our case, we are safe here, as any PRs from forks do not get the secrets exposed.
.with_env(crate::ide::web::env::CSC_FOR_PULL_REQUEST, "true"),
_ => step,
}

View File

@ -117,7 +117,7 @@ mod tests {
#[tokio::test]
async fn check_node_version() -> Result {
setup_logging()?;
setup_logging().ok();
let version = Node.parse_version("v16.13.2")?;
let requirement = VersionReq::parse("=16.15.0")?;
@ -128,7 +128,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn deserialize() -> Result {
setup_logging()?;
setup_logging().ok();
let config = r#"
# Options intended to be common for all developers.
wasm-size-limit: "4.37MB"
@ -147,7 +147,7 @@ required-versions:
#[tokio::test]
async fn deserialize_config_in_repo() -> Result {
setup_logging()?;
setup_logging().ok();
// let config = include_str!("../../../build-config.yaml");
let config = r#"# Options intended to be common for all developers.

View File

@ -66,7 +66,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn spawn() -> Result {
setup_logging()?;
setup_logging().ok();
set_current_dir(r"H:\NBO\enso5")?;
let cache = cache::Cache::new_default().await?;
cache::goodie::sbt::Sbt.install_if_missing(&cache).await?;

View File

@ -1,16 +1,19 @@
//! Code for dealing with JS/TS components of the GUI1 and the Electron client (IDE).
use crate::prelude::*;
use crate::ide::web::env::CSC_KEY_PASSWORD;
use crate::paths::generated;
use crate::project::gui::BuildInfo;
use crate::project::IsArtifact;
use crate::version::ENSO_VERSION;
use anyhow::Context;
use futures_util::future::try_join;
use ide_ci::env::known::electron_builder::WindowsSigningCredentials;
use ide_ci::io::download_all;
use ide_ci::program::command::FallibleManipulator;
use ide_ci::programs::node::NpmCommand;
use ide_ci::programs::Npm;
use sha2::Digest;
use std::process::Stdio;
use tempfile::TempDir;
@ -56,51 +59,8 @@ pub mod env {
}
// === Electron Builder ===
// Variables introduced by the Electron Builder itself.
// See: https://www.electron.build/code-signing
pub use ide_ci::env::known::electron_builder::*;
define_env_var! {
/// The HTTPS link (or base64-encoded data, or file:// link, or local path) to certificate
/// (*.p12 or *.pfx file). Shorthand ~/ is supported (home directory).
WIN_CSC_LINK, String;
/// The password to decrypt the certificate given in WIN_CSC_LINK.
WIN_CSC_KEY_PASSWORD, String;
/// The HTTPS link (or base64-encoded data, or file:// link, or local path) to certificate
/// (*.p12 or *.pfx file). Shorthand ~/ is supported (home directory).
CSC_LINK, String;
/// The password to decrypt the certificate given in CSC_LINK.
CSC_KEY_PASSWORD, String;
/// The username of apple developer account.
APPLEID, String;
/// The app-specific password (not Apple ID password). See:
/// https://support.apple.com/HT204397
APPLEIDPASS, String;
/// Apple Team ID.
APPLETEAMID, String;
/// `true` or `false`. Defaults to `true` — on a macOS development machine valid and
/// appropriate identity from your keychain will be automatically used.
CSC_IDENTITY_AUTO_DISCOVERY, bool;
/// Path to the python2 executable, used by electron-builder on macOS to package DMG.
PYTHON_PATH, PathBuf;
/// Note that enabling CSC_FOR_PULL_REQUEST can pose serious security risks. Refer to the
/// [CircleCI documentation](https://circleci.com/docs/1.0/fork-pr-builds/) for more
/// information. If the project settings contain SSH keys, sensitive environment variables,
/// or AWS credentials, and untrusted forks can submit pull requests to your repository, it
/// is not recommended to enable this option.
///
/// In our case we are careful to not expose any sensitive information to third-party forks,
/// so we can safely enable this option.
CSC_FOR_PULL_REQUEST, bool;
}
// Cloud environment configuration
define_env_var! {
@ -147,6 +107,37 @@ pub mod env {
}
}
/// Name of the directory with the unpacked Electron package.
///
/// The directory is created by the `electron-builder` utility in the output directory when run
/// with the `dir` target. It is also usually created for other targets, as it is an intermediate
/// step in the packaging process.
///
/// # Panics
/// This function panics if the provided OS and architecture combination is not supported.
pub fn unpacked_dir(output_path: impl AsRef<Path>, os: OS, arch: Arch) -> PathBuf {
let segment_name = match (os, arch) {
(OS::Linux, Arch::X86_64) => "linux-unpacked",
(OS::MacOS, Arch::AArch64) => "mac-arm64",
(OS::MacOS, Arch::X86_64) => "mac",
(OS::Windows, Arch::X86_64) => "win-unpacked",
_ => todo!("{os}-{arch} combination is not supported"),
};
output_path.as_ref().join(segment_name)
}
/// Computes the SHA-256 checksum of a file and writes it to a file.
///
/// This is a Rust equivalent of the `app/ide-desktop/lib/client/tasks/computeHashes.mjs`.
pub fn store_sha256_checksum(file: impl AsRef<Path>, checksum_file: impl AsRef<Path>) -> Result {
let mut hasher = sha2::Sha256::new();
let mut file = ide_ci::fs::open(&file)?;
std::io::copy(&mut file, &mut hasher)?;
let hash = hasher.finalize();
ide_ci::fs::write(&checksum_file, format!("{hash:x}"))?;
Ok(())
}
#[derive(Clone, Debug)]
pub struct IconsArtifacts(pub PathBuf);
@ -198,12 +189,6 @@ impl AsRef<OsStr> for Workspaces {
}
}
#[derive(Clone, Copy, Debug)]
pub enum Command {
Build,
Watch,
}
pub fn target_os_flag(os: OS) -> Result<&'static str> {
match os {
OS::Windows => Ok("--win"),
@ -307,7 +292,9 @@ impl IdeDesktop {
target_os: OS,
target: Option<String>,
) -> Result {
if TARGET_OS == OS::MacOS && CSC_KEY_PASSWORD.is_set() {
let output_path = output_path.as_ref();
let electron_config = output_path.join("electron-builder.json");
if TARGET_OS == OS::MacOS && env::CSC_KEY_PASSWORD.is_set() {
// This means that we will be doing code signing on MacOS. This requires JDK environment
// to be set up.
let graalvm =
@ -318,18 +305,19 @@ impl IdeDesktop {
crate::web::install(&self.repo_root).await?;
let pm_bundle = ProjectManagerInfo::new(project_manager)?;
let client_build = self
.npm()?
self.npm()?
.set_env(env::ENSO_BUILD_GUI, gui.as_ref())?
.set_env(env::ENSO_BUILD_IDE, output_path.as_ref())?
.set_env(env::ENSO_BUILD_IDE, output_path)?
.try_applying(&pm_bundle)?
.workspace(Workspaces::Enso)
.run("build")
.run_ok();
.run_ok()
.await?;
let icons_dist = TempDir::new()?;
let icons_dist = icons_dist.into_path();
let icons_build = self.build_icons(&icons_dist);
let (icons, _content) = try_join(icons_build, client_build).await?;
let icons = icons_build.await?;
let python_path = if TARGET_OS == OS::MacOS && !env::PYTHON_PATH.is_set() {
// On macOS electron-builder will fail during DMG creation if there is no python2
@ -358,9 +346,10 @@ impl IdeDesktop {
.try_applying(&icons)?
// .env("DEBUG", "electron-builder")
.set_env(env::ENSO_BUILD_GUI, gui.as_ref())?
.set_env(env::ENSO_BUILD_IDE, output_path.as_ref())?
.set_env(env::ENSO_BUILD_IDE, output_path)?
.set_env(env::ENSO_BUILD_PROJECT_MANAGER, project_manager.as_ref())?
.set_env_opt(env::PYTHON_PATH, python_path.as_ref())?
.set_env(enso_install_config::ENSO_BUILD_ELECTRON_BUILDER_CONFIG, &electron_config)?
.workspace(Workspaces::Enso)
// .args(["--loglevel", "verbose"])
.run("dist")
@ -370,6 +359,33 @@ impl IdeDesktop {
.run_ok()
.await?;
// On Windows we build our own installer by invoking `enso_install_config::bundler::bundle`.
if TARGET_OS == OS::Windows {
let code_signing_certificate = WindowsSigningCredentials::new_from_env()
.await
.inspect_err(|e| {
warn!("Failed to create code signing certificate from the environment: {e:?}");
})
.ok();
let ide_artifacts = crate::project::ide::Artifact::new(
target_os,
TARGET_ARCH,
&ENSO_VERSION.get()?,
output_path,
);
let config = enso_install_config::bundler::Config {
electron_builder_config: electron_config,
unpacked_electron_bundle: unpacked_dir(output_path, target_os, TARGET_ARCH),
repo_root: self.repo_root.to_path_buf(),
output_file: ide_artifacts.image.clone(),
intermediate_dir: output_path.to_path_buf(),
certificate: code_signing_certificate,
};
enso_install_config::bundler::bundle(config).await?;
store_sha256_checksum(&ide_artifacts.image, &ide_artifacts.image_checksum)?;
}
Ok(())
}
}

View File

@ -185,7 +185,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn new_download() -> Result {
setup_logging()?;
setup_logging().ok();
let path = r"C:\temp\google_fonts2";
let octocrab = github::setup_octocrab().await?;
let cache = Cache::new_default().await?;

View File

@ -29,13 +29,7 @@ impl Artifact {
version: &Version,
dist_dir: impl AsRef<Path>,
) -> Self {
let unpacked = dist_dir.as_ref().join(match target_os {
OS::Linux => "linux-unpacked",
OS::MacOS if target_arch == Arch::AArch64 => "mac-arm64",
OS::MacOS if target_arch == Arch::X86_64 => "mac",
OS::Windows => "win-unpacked",
_ => todo!("{target_os}-{target_arch} combination is not supported"),
});
let unpacked = crate::ide::web::unpacked_dir(&dist_dir, target_os, target_arch);
let unpacked_executable = match target_os {
OS::Linux => "enso",
OS::MacOS => "Enso.app",

View File

@ -328,7 +328,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn notify_cloud() -> Result {
setup_logging()?;
setup_logging().ok();
let version = Version::from_str("2022.1.1-rc.2")?;
notify_cloud_about_gui(&version).await?;
Ok(())
@ -337,7 +337,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn release_assets() -> Result {
setup_logging()?;
setup_logging().ok();
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = crate_dir.parent().unwrap().parent().unwrap();

View File

@ -43,7 +43,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn manual_call() -> Result {
setup_logging()?;
setup_logging().ok();
let octo = setup_octocrab().await?;
build_image_workflow_dispatch_input(&octo, &Version::parse("2022.1.1-nightly.2022-10-18")?)
.await?;

View File

@ -11,7 +11,7 @@ async-trait = "0.1.78"
bincode = "1.3.3"
byte-unit = { workspace = true }
bytes = { workspace = true }
chrono = { version = "0.4.19", features = ["serde"] }
chrono = { workspace = true }
clap = { workspace = true }
data-encoding = "2.3.2"
dependency_runner = "1.1.0"
@ -20,7 +20,7 @@ derive_more = { workspace = true }
dirs = { workspace = true }
enso-build-base = { path = "../base" }
enso-zst = { path = "../../lib/rust/zst" }
flate2 = "1.0.22"
flate2 = { workspace = true }
flume = "0.10.10"
fs_extra = "1.3.0"
futures = { workspace = true }
@ -29,15 +29,14 @@ glob = "0.3.0"
headers = "0.3.7"
heck = "0.4.0"
http-serde = "1.1.0"
indicatif = { version = "0.17.1", features = ["tokio"] }
indicatif = { workspace = true }
itertools = { workspace = true }
lazy_static = { workspace = true }
log = "0.4.14"
mime = "0.3.16"
multimap = "0.8.3"
multimap = { workspace = true }
new_mime_guess = "4.0.0"
octocrab = { workspace = true }
path-absolutize = "3.0.11"
path-absolutize = { workspace = true }
pathdiff = "0.2.1"
path-slash = "0.2.1"
platforms = { workspace = true }
@ -53,19 +52,20 @@ sha2 = "0.10.2"
strum = { workspace = true }
symlink = "0.1.0"
sysinfo = { workspace = true }
tar = "0.4.37"
tar = { workspace = true }
tempfile = "3.2.0"
tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
tracing = { version = "0.1.37" }
tracing-subscriber = { version = "0.3.11", features = ["env-filter"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
unicase = "2.6.0"
url = "2.2.2"
uuid = { version = "1.1.0", features = ["v4", "serde"] }
walkdir = "2.3.2"
which = "4.2.2"
walkdir = { workspace = true }
which = "5.0.0"
zip = { version = "0.6.2", default-features = false, features = ["deflate"] }
base64 = "0.21.0"
[dev-dependencies]
warp = { version = "0.3.2", default-features = false }

View File

@ -286,7 +286,7 @@ mod tests {
#[ignore]
async fn test_upload() -> Result {
use warp::Filter;
setup_logging()?;
setup_logging().ok();
let response1 = CreateArtifactResponse {
name: "test-artifact".to_string(),

View File

@ -7,6 +7,37 @@ use std::fs::File;
/// Synchronous version of [`extract_files`].
#[context("Failed to extract files from the archive.")]
pub fn extract_files_sync<R: Read>(
mut archive: tar::Archive<R>,
mut filter: impl FnMut(&tar::Entry<R>) -> Option<PathBuf>,
) -> Result {
let entries = archive.entries()?;
for entry in entries {
let mut entry = entry?;
let path_in_archive = entry.path()?.to_path_buf();
if let Some(output_path) = filter(&entry) {
let entry_type = entry.header().entry_type();
let make_message = |prefix, path: &Path| {
format!(
"{} {:?} entry: {} => {}",
prefix,
entry_type,
path.display(),
output_path.display()
)
};
trace!("{}", make_message("Extracting", &path_in_archive));
entry
.unpack(&output_path)
.with_context(|| make_message("Failed to extract", &path_in_archive))?;
}
}
Ok(())
}
// ===============
// === Archive ===
// ===============
@ -36,34 +67,13 @@ impl Archive {
}
/// Synchronous version of [`extract_files`].
#[context("Failed to extract files from archive {}", self.path.display())]
pub fn extract_files_sync(
mut self,
mut filter: impl FnMut(&Path) -> Option<PathBuf>,
self,
filter: impl FnMut(&tar::Entry<GzDecoder<File>>) -> Option<PathBuf>,
) -> Result {
let entries = self.file.entries()?;
for entry in entries {
let mut entry = entry?;
let path_in_archive = entry.path()?;
if let Some(output_path) = filter(&path_in_archive) {
let entry_type = entry.header().entry_type();
let make_message = |prefix, path: Cow<Path>| {
format!(
"{} {:?} entry: {} => {}",
prefix,
entry_type,
path.display(),
output_path.display()
)
};
trace!("{}", make_message("Extracting", path_in_archive));
entry.unpack(&output_path).with_context(|| {
make_message("Failed to extract", entry.path().unwrap_or_default())
})?;
}
}
Ok(())
extract_files_sync(self.file, filter).with_context(|| {
format!("Failed to extract files from archive {}", self.path.display())
})
}
/// Extract all files from the specified subtree in the archive, placing them in the specified
@ -99,7 +109,8 @@ impl Archive {
}
impl ExtractFiles for Archive {
async fn extract_files(self, filter: impl FnMut(&Path) -> Option<PathBuf>) -> Result {
async fn extract_files(self, mut filter: impl FnMut(&Path) -> Option<PathBuf>) -> Result {
let filter = move |entry: &tar::Entry<GzDecoder<File>>| filter(entry.path().ok()?.as_ref());
let job = move || self.extract_files_sync(filter);
tokio::task::block_in_place(job)
}

View File

@ -189,7 +189,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn cache_test() -> Result {
setup_logging()?;
setup_logging().ok();
let download_task = DownloadFile::new("https://store.akamai.steamstatic.com/public/shared/images/header/logo_steam.svg?t=962016")?;
let cache = Cache::new("C:/temp/enso-cache").await?;

View File

@ -13,6 +13,7 @@ use unicase::UniCase;
// ==============
pub mod accessor;
pub mod consts;
pub mod known;

View File

@ -104,6 +104,12 @@ impl const From<&'static str> for PathBufVariable {
}
}
impl AsRef<str> for PathBufVariable {
fn as_ref(&self) -> &str {
self.0
}
}
impl RawVariable for PathBufVariable {
fn name(&self) -> &str {
self.0

5
build/ci_utils/src/env/consts.rs vendored Normal file
View File

@ -0,0 +1,5 @@
/// File extension for Windows shortcut files including the dot.
pub const SHORTCUT_EXTENSION: &str = ".lnk";
/// File suffix for Windows shortcut files without the dot.
pub const SHORTCUT_SUFFIX: &str = "lnk";

View File

@ -1,9 +1,20 @@
//! Universally known environment variables.
use crate::prelude::*;
use crate::define_env_var;
use crate::env::accessor::PathBufVariable;
use crate::env::accessor::PathLike;
// ==============
// === Export ===
// ==============
pub mod cargo;
pub mod electron_builder;
/// PATH environment variable.
///
@ -11,13 +22,58 @@ use crate::env::accessor::PathLike;
/// executable files.
pub const PATH: PathLike = PathLike("PATH");
/// Windows-specific environment variables.
pub mod win {
use super::*;
define_env_var! {
/// Per-user custom settings and other information needed by applications.
///
/// Example: `C:\Users\{username}\AppData\Roaming`
APPDATA, PathBuf;
/// Per-user custom settings and other information needed by applications that do not apply
/// when the user roams.
LOCALAPPDATA, PathBuf;
/// Directory where all programs can store their global data.
PROGRAMDATA, PathBuf;
/// Directory where programs are installed (native architecture).
PROGRAMFILES, PathBuf;
/// The user's home directory.
USERPROFILE, PathBuf;
}
/// Directory where 32-bit programs are installed.
pub const PROGRAMFILES_X86: PathBufVariable = PathBufVariable("ProgramFiles(x86)");
/// Directory containing user's Start menu programs shortcuts.
pub fn start_menu_programs() -> Result<PathBuf> {
Ok(APPDATA.get()?.join_iter(["Microsoft", "Windows", "Start Menu", "Programs"]))
}
}
define_env_var! {
/// Variable in Unix-like systems that overrides individual `LC_*` settings for locale-specific
/// program behavior, such as time formatting (`LC_TIME`), string sorting (`LC_COLLATE`), and
/// currency formatting (`LC_MONETARY`). Setting `LC_ALL` ensures uniform application of locale
/// settings, commonly utilized in scripting and debugging to maintain consistency irrespective
/// of user-specific configurations.
/// Overrides individual `LC_*` settings for consistent locale-specific behavior across programs.
/// - [`LC_TIME`]: Defines formatting for dates and times.
/// - [`LC_COLLATE`]: Determines the sorting order of strings, influencing string comparison operations.
/// - [`LC_MONETARY`]: Sets the format for monetary values, including currency symbols and decimal separators.
/// Use `LC_ALL` to uniformly apply these settings, which is especially useful in scripts or when debugging
/// to avoid locale-related inconsistencies.
LC_ALL, String;
/// Defines formatting for dates and times.
LC_TIME, String;
/// Determines the sorting order of strings.
LC_COLLATE, String;
/// Sets the format for monetary values.
LC_MONETARY, String;
}
/// The `C.UTF-8` locale, when used as a value for [`LC_ALL`] or other `LC_*` environment variables

8
build/ci_utils/src/env/known/cargo.rs vendored Normal file
View File

@ -0,0 +1,8 @@
//! Environment variables known to Cargo.
// ==============
// === Export ===
// ==============
pub mod build;

View File

@ -0,0 +1,12 @@
//! Environment variables that are set by Cargo for the build script run.
//!
//! See [Cargo's documentation](https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-build-scripts).
crate::define_env_var! {
/// The folder in which all output and intermediate artifacts should be placed. This folder is
/// inside the build directory for the package being built, and it is unique for the package in
/// question.
OUT_DIR, PathBuf;
}

View File

@ -0,0 +1,161 @@
//! Environment variables used by the Electron Builder.
use crate::prelude::*;
use crate::define_env_var;
use base64::Engine;
use std::io::Write;
define_env_var! {
/// The HTTPS link (or base64-encoded data, or file:// link, or local path) to certificate
/// (*.p12 or *.pfx file). Shorthand ~/ is supported (home directory).
WIN_CSC_LINK, String;
/// The password to decrypt the certificate given in WIN_CSC_LINK.
WIN_CSC_KEY_PASSWORD, String;
/// The HTTPS link (or base64-encoded data, or file:// link, or local path) to certificate
/// (*.p12 or *.pfx file). Shorthand ~/ is supported (home directory).
CSC_LINK, String;
/// The password to decrypt the certificate given in CSC_LINK.
CSC_KEY_PASSWORD, String;
/// The username of apple developer account.
APPLEID, String;
/// The app-specific password (not Apple ID password). See:
/// https://support.apple.com/HT204397
APPLEIDPASS, String;
/// Apple Team ID.
APPLETEAMID, String;
/// `true` or `false`. Defaults to `true` — on a macOS development machine valid and
/// appropriate identity from your keychain will be automatically used.
CSC_IDENTITY_AUTO_DISCOVERY, bool;
/// Path to the python2 executable, used by electron-builder on macOS to package DMG.
PYTHON_PATH, PathBuf;
/// Note that enabling CSC_FOR_PULL_REQUEST can pose serious security risks. Refer to the
/// [CircleCI documentation](https://circleci.com/docs/1.0/fork-pr-builds/) for more
/// information. If the project settings contain SSH keys, sensitive environment variables,
/// or AWS credentials, and untrusted forks can submit pull requests to your repository, it
/// is not recommended to enable this option.
CSC_FOR_PULL_REQUEST, bool;
}
/// CSC (Code Signing Certificate) link.
///
/// This models the way Electron Builder uses to recieve the certificate file.
#[derive(Clone, Debug)]
pub enum CscLink {
/// Local path to the certificate file.
FilePath(PathBuf),
/// HTTPS link to the certificate file.
Url(Url),
/// The certificate file contents.
Data(Vec<u8>),
}
impl std::str::FromStr for CscLink {
type Err = anyhow::Error;
#[context("Failed to parse CSC link from '{csc_link}'.")]
fn from_str(csc_link: &str) -> Result<Self> {
let csc_link = csc_link.trim();
if let Some(file_path) = csc_link.strip_prefix("file://") {
Ok(Self::FilePath(file_path.into()))
} else if let Some(url) = csc_link.strip_prefix("https://") {
Ok(Self::Url(url.parse()?))
} else if csc_link.len() > 2048 {
let contents = base64::engine::general_purpose::STANDARD
.decode(csc_link)
.context("Failed to decode base64-encoded CSC link.")?;
Ok(Self::Data(contents))
} else {
Ok(Self::FilePath(csc_link.into()))
}
}
}
impl CscLink {
/// Create a new certificate file from the environment variable.
pub fn new_from_env() -> Result<Self> {
let csc_link = WIN_CSC_LINK.get().or_else(|_| CSC_LINK.get())?;
Self::from_str(&csc_link)
}
}
/// CSC certificate file to be used for signing the Windows build.
#[derive(Debug)]
pub enum CodeSigningCertificate {
/// Local certificate file.
FilePath(PathBuf),
/// Temporarily created certificate file.
TempFile(tempfile::TempPath),
}
impl AsRef<Path> for CodeSigningCertificate {
fn as_ref(&self) -> &Path {
match self {
Self::FilePath(path) => path.as_ref(),
Self::TempFile(path) => path.as_ref(),
}
}
}
impl CodeSigningCertificate {
/// Create a new certificate file from the given link.
pub async fn new(link: CscLink) -> Result<Self> {
let ret = match link {
CscLink::FilePath(path) => Self::FilePath(path),
CscLink::Url(url) => {
let temp_file = tempfile::NamedTempFile::new()?.into_temp_path();
crate::io::web::download_file(url, &temp_file).await?;
Self::TempFile(temp_file)
}
CscLink::Data(contents) => {
let temp_file = tempfile::NamedTempFile::new()?;
temp_file.as_file().write_all(&contents)?;
Self::TempFile(temp_file.into_temp_path())
}
};
Ok(ret)
}
/// Create a new certificate file from the environment variable.
pub async fn new_from_env() -> Result<Self> {
let csc_link = CscLink::new_from_env()?;
Self::new(csc_link).await
}
}
/// Data needed to sign the binaries on Windows.
#[derive(Debug)]
pub struct WindowsSigningCredentials {
/// Code signing certificate file.
pub certificate: CodeSigningCertificate,
/// Password to the certificate.
pub password: String,
}
impl WindowsSigningCredentials {
/// Create a new certificate file from the environment variable.
pub async fn new_from_env() -> Result<Self> {
let certificate = CodeSigningCertificate::new_from_env().await?;
let password = WIN_CSC_KEY_PASSWORD.get().or_else(|_| CSC_KEY_PASSWORD.get())?;
Ok(Self { certificate, password })
}
/// Sign the given binary.
pub async fn sign(&self, exe: impl AsRef<Path>) -> Result {
crate::programs::signtool::sign(exe, self.certificate.as_ref(), &self.password).await
}
}

View File

@ -11,6 +11,19 @@ pub fn metadata<P: AsRef<Path>>(path: P) -> BoxFuture<'static, Result<std::fs::M
tokio::fs::metadata(path).anyhow_err().boxed()
}
/// Like [tokio::fs::rename] but with a better error message and static lifetime.
pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> BoxFuture<'static, Result<()>> {
let from = from.as_ref().to_owned();
let to = to.as_ref().to_owned();
tokio::fs::rename(from.clone(), to.clone())
.with_context(move || {
format!("Failed to rename file from: {} to: {}", from.display(), to.display())
})
.boxed()
}
/// See [tokio::fs::symlink_metadata].
pub fn symlink_metadata<P: AsRef<Path>>(path: P) -> BoxFuture<'static, Result<std::fs::Metadata>> {
let path = path.as_ref().to_owned();

View File

@ -4,87 +4,72 @@ use tracing_subscriber::prelude::*;
use crate::global;
use std::io;
use std::sync::Once;
use tracing::span::Attributes;
use tracing::subscriber::Interest;
use tracing::Event;
use tracing::Id;
use tracing::Metadata;
use tracing::Subscriber;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
use tracing_subscriber::Registry;
pub fn is_our_module_path(path: impl AsRef<str>) -> bool {
// true
["ide_ci::", "enso"].into_iter().any(|prefix| path.as_ref().starts_with(prefix))
}
/// A layer that filters out all spans/events that are not in our module path.
#[derive(Clone, Copy, Debug, Display)]
pub struct MyLayer;
pub struct GlobalFilteringLayer;
impl<S: Subscriber + Debug + for<'a> LookupSpan<'a>> tracing_subscriber::Layer<S> for MyLayer {
impl<S: Subscriber + Debug + for<'a> LookupSpan<'a>> Layer<S> for GlobalFilteringLayer {
fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest {
if metadata.module_path().is_some_and(is_our_module_path) {
Interest::always()
} else {
// dbg!(metadata);
Interest::never()
}
}
fn on_new_span(
&self,
_attrs: &Attributes<'_>,
_id: &Id,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
// let span = ctx.span(id).unwrap();
// let bar = crate::global::new_spinner(format!("In span {id:?}: {:?}", span.name()));
// span.extensions_mut().insert(bar);
// crate::global::println(format!("Create {id:?}"));
}
fn on_event(&self, _event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
// tracing_log::dbg!(event);
}
fn on_enter(&self, _id: &Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
// ide_ci::global::println(format!("Enter {id:?}"));
}
fn on_exit(&self, _id: &Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
// ide_ci::global::println(format!("Leave {id:?}"));
}
fn on_close(&self, _id: Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
// crate::global::println(format!("Close {id:?}"));
}
}
/// Layer that prints logs to stderr.
///
/// It uses the `ENSO_BUILD_LOG` environment variable to determine the [log filtering](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives).
pub fn stderr_log_layer<S>() -> impl Layer<S> + Debug
where S: Subscriber + for<'a> LookupSpan<'a> + Debug + Send + Sync + 'static {
let filter = tracing_subscriber::EnvFilter::builder()
.with_env_var("ENSO_BUILD_LOG")
.with_default_directive(LevelFilter::TRACE.into())
.from_env_lossy();
let progress_bar_writer = IndicatifWriter::new();
tracing_subscriber::fmt::layer()
.without_time()
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_writer(progress_bar_writer)
.with_filter(filter)
}
pub fn file_log_layer<S>(file: std::fs::File) -> impl Layer<S> + Debug
where S: Subscriber + for<'a> LookupSpan<'a> + Debug + Send + Sync + 'static {
tracing_subscriber::fmt::layer()
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_thread_names(true)
.with_writer(file)
}
/// Install global `tracing` subscriber that logs to stderr.
///
/// Should be called only once, otherwise it will fail.
///
/// When using this function in unit tests, the result should be ignored, to allow multiple tests
/// to be run in a single batch.
pub fn setup_logging() -> Result {
static GUARD: Once = Once::new();
GUARD.call_once(|| {
let filter = tracing_subscriber::EnvFilter::builder()
.with_env_var("ENSO_BUILD_LOG")
.with_default_directive(LevelFilter::TRACE.into())
.from_env_lossy();
let progress_bar_writer = IndicatifWriter::new();
tracing::subscriber::set_global_default(
Registry::default().with(MyLayer).with(
tracing_subscriber::fmt::layer()
.without_time()
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_writer(progress_bar_writer)
.with_filter(filter),
),
)
.unwrap()
});
Ok(())
let registry = Registry::default().with(GlobalFilteringLayer).with(stderr_log_layer());
tracing::subscriber::set_global_default(registry)
.context("Failed to set global default subscriber.")
}

View File

@ -60,7 +60,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn lookup_dependencies() -> Result {
setup_logging()?;
setup_logging().ok();
vs::apply_dev_environment().await?;
let binary = Path::new(
r"H:\NBO\enso\built-distribution\enso-engine-2024.1.1-dev-windows-amd64\enso-2024.1.1-dev\component\enso_parser.dll",

View File

@ -145,6 +145,12 @@ pub trait IsCommandWrapper {
self
}
#[cfg(windows)]
fn raw_arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
self.borrow_mut_command().raw_arg(arg);
self
}
fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,

View File

@ -6,6 +6,7 @@ pub mod cargo;
pub mod cmake;
pub mod cmd;
pub mod docker;
pub mod explorer;
pub mod flatc;
pub mod git;
pub mod go;
@ -23,6 +24,7 @@ pub mod sbt;
pub mod seven_zip;
pub mod sh;
pub mod shaderc;
pub mod signtool;
pub mod spirv_cross;
pub mod strip;
pub mod tar;

View File

@ -8,6 +8,7 @@ use crate::program::command::Manipulator;
// === Export ===
// ==============
pub mod build;
pub mod build_env;
pub mod clippy;
pub mod fmt;

View File

@ -0,0 +1,22 @@
//! Directives for Cargo build scripts.
//!
//! Should be used in `build.rs` files to communicate with Cargo.
use crate::prelude::*;
/// Sets an environment variable.
pub fn expose_env_var(var_name: impl AsRef<str>, var_value: impl AsRef<str>) {
println!("cargo:rustc-env={}={}", var_name.as_ref(), var_value.as_ref());
}
/// Tells Cargo to rerun the build script if the given file changes.
pub fn rerun_if_file_changed(file_path: impl AsRef<Path>) {
println!("cargo:rerun-if-changed={}", file_path.as_ref().display());
}
/// Tells Cargo to rerun the build script if the given environment variable changes.
pub fn rerun_if_env_changed(var_name: impl AsRef<str>) {
println!("cargo:rerun-if-env-changed={}", var_name.as_ref());
}

View File

@ -690,8 +690,11 @@ mod tests {
}
#[tokio::test]
// Don't run by default, user might not have privileges to run docker, or the Docker might not
// be configured to run native containers.
#[ignore]
async fn build_test_linux() -> Result {
setup_logging()?;
setup_logging().ok();
let temp = tempfile::tempdir()?;
if Docker.lookup().is_err() {
info!("Docker not found, skipping test.");

View File

@ -0,0 +1,43 @@
//! Windows File Explorer-related utilities.
//!
//! While this module builds cross-platform, it is only useful on Windows.
use crate::prelude::*;
#[derive(Debug, Clone, Copy)]
pub struct Explorer;
impl Program for Explorer {
fn executable_name(&self) -> &str {
"explorer"
}
}
/// Open the parent folder of the given path in Windows File Explorer and select the given path.
// Windows only, due to platform-specific `raw_arg` usage.
#[cfg(windows)]
pub fn show_selected(path: impl AsRef<Path>) -> Result {
let argument = format!(r#"/select,"{}""#, path.as_ref().display());
// We use `raw_arg` to avoid escaping the path. The usual quoting rules don't work here.
// We ignore the child, as we don't need to wait for it to finish.
let _child = Explorer.cmd()?.raw_arg(argument).spawn();
Ok(())
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[cfg(windows)]
#[test]
#[ignore]
fn show_my_path() {
setup_logging().ok();
let path = std::env::current_exe().unwrap();
show_selected(path).unwrap();
}
}

View File

@ -122,7 +122,7 @@ mod tests {
#[tokio::test]
async fn test_cleaning() -> Result {
setup_logging()?;
setup_logging().ok();
let dir = tempfile::tempdir()?;
let dir = dir.path();
crate::fs::tokio::reset_dir(dir).await?;

View File

@ -18,7 +18,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn call_npx() -> Result {
setup_logging()?;
setup_logging().ok();
Npx.cmd()?.run_ok().await?;
Ok(())
}

View File

@ -15,8 +15,8 @@ impl Program for SevenZip {
}
fn default_locations(&self) -> Vec<PathBuf> {
if let Ok(program_files) = std::env::var("ProgramFiles") {
let path = PathBuf::from(program_files).join("7-Zip");
if let Ok(program_files) = crate::env::known::win::PROGRAMFILES.get() {
let path = program_files.join("7-Zip");
if path.exists() {
return vec![path];
}

View File

@ -0,0 +1,97 @@
//! Utilities for [`signtool`](SignTool) CLI tool.
use crate::prelude::*;
/// The RFC3161-compliant timestamp server used for signing.
pub const TIMESTAMP_SERVER: &str = "http://timestamp.digicert.com";
/// The hash algorithms that can be used for signing.
#[derive(Clone, Copy, Debug)]
pub enum HashAlgorithm {
/// SHA-1 hash algorithm.
SHA1,
/// SHA-256 hash algorithm.
SHA256,
}
/// Compatible with format expected by the `signtool` utility CLI.
impl AsRef<str> for HashAlgorithm {
fn as_ref(&self) -> &str {
match self {
HashAlgorithm::SHA1 => "SHA1",
HashAlgorithm::SHA256 => "SHA256",
}
}
}
impl AsRef<OsStr> for HashAlgorithm {
fn as_ref(&self) -> &OsStr {
AsRef::<str>::as_ref(self).as_ref()
}
}
/// SignTool utility, being part of the Windows SDK.
///
/// `signtool` can be used to sign executables, verify signatures, or timestamp files.
#[derive(Clone, Copy, Debug)]
pub struct SignTool;
impl Program for SignTool {
fn executable_name(&self) -> &str {
"signtool"
}
fn default_locations(&self) -> Vec<PathBuf> {
if let Ok(sdk_dir) = locate_windows_sdk() {
vec![sdk_dir.join("App Certification Kit")]
} else {
Vec::new()
}
}
}
/// Heuristically locate the Windows SDK.
///
/// The `signtool` utility is part of the Windows SDK, so we need to locate it to use it.
pub fn locate_windows_sdk() -> Result<PathBuf> {
let program_files = crate::env::known::win::PROGRAMFILES_X86.get()?;
let sdk_dir = program_files.join_iter(["Windows Kits", "10"]);
// TODO: If we ever want anything more fancy, we should use the `InstallationFolder` key at
// `HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Microsoft SDKs\Windows\v10.0`
// For now we don't need it, and using `winreg` crate is too much trouble cross-platform.
if sdk_dir.exists() {
Ok(sdk_dir)
} else {
bail!("Windows SDK not found!")
}
}
/// Sign the given executable with the given certificate.
///
/// The hash algorithm used for signing is SHA-256, as SHA-1 is deprecated and not trusted.
/// The only reason not use SHA-256 would be to target OS versions older than Windows XP SP3, which
/// are not supported anyway.
pub async fn sign(
exe: impl AsRef<Path>,
cert: impl AsRef<Path>,
password: impl AsRef<str>,
) -> Result {
SignTool
.cmd()?
.arg("sign")
.arg("/f")
.arg(cert.as_ref())
.arg("/p")
.arg(password.as_ref())
.arg("/fd")
.arg(HashAlgorithm::SHA256)
.arg("/tr")
.arg(TIMESTAMP_SERVER)
.arg("/td")
.arg(HashAlgorithm::SHA256)
.arg(exe.as_ref())
.run_ok()
.await
}

View File

@ -283,7 +283,7 @@ pub mod tests {
#[tokio::test]
async fn test_directory_packing() -> Result {
setup_logging()?;
setup_logging().ok();
let archive_temp = tempfile::tempdir()?;
let archive_path = archive_temp.path().join("archive.tar.gz");

View File

@ -47,7 +47,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test_listing_dlls() -> Result {
setup_logging()?;
setup_logging().ok();
apply_dev_environment().await?;
let dlls = list_crt_dlls(vs::Platforms::local()?).await?;
dbg!(&dlls);

View File

@ -21,7 +21,7 @@ octocrab = { workspace = true }
tempfile = "3.2.0"
tokio = { workspace = true }
toml = "0.5.9"
tracing = { version = "0.1.37" }
tracing = { workspace = true }
[lints]
workspace = true

46
build/install/Cargo.toml Normal file
View File

@ -0,0 +1,46 @@
[package]
name = "enso-install"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { workspace = true }
dirs = { workspace = true }
enso-build-base = { path = "../base" }
enso-install-config = { path = "config" }
flate2 = { workspace = true }
ide-ci = { path = "../ci_utils" }
indicatif = { workspace = true }
named-lock = "0.4.1"
self-replace = "1.3.7"
serde_json = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
tar = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
windows = { version = "0.53.0", features = [
"Win32",
"Win32_UI",
"Win32_UI_Shell",
"Win32_System",
"Win32_System_LibraryLoader",
"Win32_Foundation",
"Win32_System_Com",
] }
[target.'cfg(windows)'.dependencies]
mslnk = "0.1.8"
native-windows-gui = { workspace = true }
winreg = { workspace = true }
[build-dependencies]
embed-resource = "2.4.0"
ide-ci = { path = "../ci_utils" }
enso-install-config = { path = "config" }
[lints]
workspace = true

93
build/install/build.rs Normal file
View File

@ -0,0 +1,93 @@
//! The build script of crate that is dependency of both the installer and the uninstaller.
//!
//! Currently, it is used to include the resources (icons, manifests, version information) into the
//! installer and uninstaller binaries.
use enso_install_config::prelude::*;
use enso_install_config::embed_resource_from_file;
use enso_install_config::sanitize_and_expose_electron_builder_config;
use enso_install_config::ResourceType;
use enso_install_config::ENSO_ICON_ID;
use ide_ci::programs::cargo::build_env::OUT_DIR;
/// Build-script-time function that tries to embed the icon into the binary.
///
/// Works only on Windows, on other platforms this is effectively a no-op.
fn try_embedding_icon() -> Result {
let config = enso_install_config::electron_builder_config_from_env()?;
embed_resource_from_file(ENSO_ICON_ID, ResourceType::Icon, &config.win.icon)
}
fn main() {
setup_logging().ok();
// Ignore error, not compiling the icon is not a big deal, especially if we compile to check, to
// not generate final package. (Obtaining icon is a bit of a hassle, as it is generated
// temporarily by the enso-build.)
if let Err(err) = try_embedding_icon() {
// We do not use `cargo:warning` here, as we do not want to pollute the output if the icon
// is not available. Still, to enable debugging, we print to stderr, which is captured by
// the cargo and stored in `target/debug/build/<pkg>/output`.
eprintln!("Failed to embed icon: {err:?}");
}
let config = sanitize_and_expose_electron_builder_config();
if let Ok(config) = config {
// Content on the rc file with the version information.
let version_rc = format!(
r#"
#include "winres.h"
#define IDS_VERSION_INFO 1
VS_VERSION_INFO VERSIONINFO
FILEVERSION {major},{minor},{patch},0
PRODUCTVERSION {major},{minor},{patch},0
FILEOS 0x4
FILETYPE 0x1
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "{company}"
VALUE "FileVersion", "{version}"
VALUE "LegalCopyright", "{copyright}"
VALUE "ProductName", "{name}"
VALUE "ProductVersion", "{version}"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
"#,
major = config.extra_metadata.version.major,
minor = config.extra_metadata.version.minor,
patch = config.extra_metadata.version.patch,
company = config.copyright,
name = config.product_name,
version = config.extra_metadata.version,
copyright = config.copyright
);
let path = OUT_DIR.get().unwrap().join("version.rc");
ide_ci::fs::write_if_different(&path, version_rc).unwrap();
embed_resource::compile(&path, embed_resource::NONE);
}
// Embed the manifest file.
// Necessary to avoid the issue with `GetWindowSubclass`. See:
// * https://github.com/gabdube/native-windows-gui/issues/251
// * https://github.com/microsoft/windows-rs/issues/1294
let manifest_path = Path::new("enso-install.manifest");
assert!(manifest_path.exists(), "Manifest file does not exist: {}", manifest_path.display());
let rc_path = OUT_DIR.get().unwrap().join("manifest.rc");
ide_ci::fs::write_if_different(
&rc_path,
format!("#define RT_MANIFEST 24\n1 RT_MANIFEST \"{}\"", manifest_path.display()),
)
.unwrap();
embed_resource::compile(&rc_path, embed_resource::NONE);
}

View File

@ -0,0 +1,16 @@
[package]
name = "enso-install-config"
version = "0.1.0"
edition = "2021"
[dependencies]
embed-resource = "2.4.0"
enso-build-base = { path = "../../base" }
ide-ci = { path = "../../ci_utils" }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = "3.2.0"
walkdir = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,104 @@
//! Utilities for building the Windows installer/uninstaller for the Enso application.
//!
//! See the [`bundle`] function as the main entry point.
use crate::prelude::*;
use crate::INSTALLER_NAME;
use crate::UNINSTALLER_NAME;
use ide_ci::env::known::electron_builder::WindowsSigningCredentials;
use ide_ci::programs::cargo;
use ide_ci::programs::Cargo;
/// Input necessary to generate a Windows installer from unpacked Electron application bundle.
#[derive(Debug)]
pub struct Config {
/// File to the JSON file containing the Electron Builder configuration.
pub electron_builder_config: PathBuf,
/// Path to the directory containing the unpacked Electron application bundle.
///
/// It is obtained by running the `electron-builder` with the `--dir` option.
pub unpacked_electron_bundle: PathBuf,
/// Path to the root of the repository.
pub repo_root: PathBuf,
/// Path where the generated installer should be saved.
pub output_file: PathBuf,
/// Path to the directory where intermediate files should be stored.
pub intermediate_dir: PathBuf,
/// Certificate used to sign the installer and uninstaller.
///
/// If `None`, the installer and uninstaller will not be signed.
pub certificate: Option<WindowsSigningCredentials>,
}
/// Builds a package using Cargo and optionally signs it with a certificate.
pub async fn build_package(
crate_name: &str,
output_file: &Path,
certificate: Option<&WindowsSigningCredentials>,
prepare_env: impl FnOnce(&mut Command) -> Result<&mut Command>,
) -> Result {
let temp_dir = tempfile::tempdir()?;
let mut cmd = Cargo.cmd()?;
prepare_env(&mut cmd)?;
cmd.apply(&cargo::Command::Build)
.arg("--release")
.arg("--package")
.arg(crate_name)
.arg("-Z")
.arg("unstable-options")
.arg("--out-dir")
.arg(temp_dir.path())
.run_ok()
.await?;
let built_exe = temp_dir.path().join(crate_name).with_executable_extension();
if let Some(certificate) = certificate {
certificate.sign(&built_exe).await?;
}
ide_ci::fs::tokio::copy(&built_exe, output_file).await?;
Ok(())
}
/// Package the Enso unpacked Electron application bundle (electron-builder's output) into an
/// installer.
///
/// First, the uninstaller is built and signed. Then, the payload is prepared and the installer is
/// built and signed.
pub async fn bundle(config: Config) -> Result {
let Config {
electron_builder_config,
unpacked_electron_bundle,
repo_root,
output_file,
intermediate_dir,
certificate,
} = config;
// The uninstaller must be built first, as it is part of the distribution package.
let uninstaller_path =
unpacked_electron_bundle.join(UNINSTALLER_NAME).with_executable_extension();
build_package(UNINSTALLER_NAME, &uninstaller_path, certificate.as_ref(), |cmd| {
cmd.current_dir(&repo_root);
cmd.set_env(crate::ENSO_BUILD_ELECTRON_BUILDER_CONFIG, &electron_builder_config)
})
.await?;
// Prepare the archive payload for the installer.
let archive_path = intermediate_dir.join("payload.tar.gz");
let metadata_path = intermediate_dir.join("payload.json");
crate::payload::prepare_payload(&unpacked_electron_bundle, &archive_path, &metadata_path)
.await?;
// Finally build the installer.
build_package(INSTALLER_NAME, &output_file, certificate.as_ref(), |cmd| {
cmd.current_dir(&repo_root)
.set_env(crate::ENSO_INSTALL_ARCHIVE_PATH, &archive_path)?
.set_env(crate::ENSO_INSTALL_METADATA_PATH, &metadata_path)?
.set_env(crate::ENSO_BUILD_ELECTRON_BUILDER_CONFIG, &electron_builder_config)
})
.await
}

View File

@ -0,0 +1,133 @@
//! Rust representation of the `electron-builder` configuration subset.
use crate::prelude::*;
use serde::Deserialize;
use serde::Serialize;
/// Additional configuration needed by the installer that is not part of the standard
/// `electron-builder` configuration.
///
/// This configuration is received through `installer` field in the `extraMetadata`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallerConfig {
/// The company name to be used in the installer. Example: `"New Byte Order sp. z o.o."`.
pub publisher: String,
/// Extended file association configuration.
pub file_associations: Vec<ExtendedFileAssociation>,
}
/// A subset of the configuration options available in the `electron-builder` configuration.
///
/// Note that some fields should not be included here (like code signing options) as this
/// configuration might end up being compiled into and shipped with the application installer.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
/// The application ID, usually in reverse domain name notation. Example: `"org.enso"`.
pub app_id: String,
/// The name of the product. Example: `"Enso"`.
pub product_name: String,
/// Extra metadata for the application, like version. Example: `{"version": "2023.2.1-dev"}`.
pub extra_metadata: ExtraMetadata,
/// Copyright notice of the application. Example: `"Copyright © 2023 New Byte Order sp. z
/// o.o."`.
pub copyright: String,
/// Pattern for naming artifact files. Example: `"enso-${os}-2023.2.1-dev.${ext}"`.
pub artifact_name: String,
/// Custom protocol schemes that the application handles. Example: `[{ "name": "Enso url",
/// "schemes": ["enso"], "role": "Editor" }]`.
pub protocols: Vec<Protocol>,
/// Configuration specific to Windows builds.
pub win: WinConfig,
/// File associations for the application. Example: `[{ "ext": "enso", "name": "Enso Source
/// File", "role": "Editor" }, {...}]`.
///
/// Installer uses extended version of this configuration, see [`InstallerConfig`].
pub file_associations: Vec<FileAssociation>,
/// Configuration for the output directories. Example: `{ "output":
/// "/home/mwu/Desktop/enso/dist/ide2" }`.
pub directories: Directories,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtraMetadata {
/// Version of the application. Example: `"2023.2.1-dev"`.
pub version: Version,
/// Additional configuration needed by the installer.
pub installer: InstallerConfig,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Protocol {
/// Name of the protocol. Example: `"Enso url"`.
pub name: String,
/// Schemes associated with the protocol. Example: `["enso"]`.
pub schemes: Vec<String>,
/// Role of the application in handling the protocol. Example: `"Editor"`.
pub role: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WinConfig {
/// Path to the icon file for Windows. Example: `"/tmp/.tmpMPYWpz/icon.ico"`.
pub icon: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileAssociation {
/// File extension to associate. Example: `"enso"`.
pub ext: String,
/// Name of the file type. Example: `"Enso Source File"`.
pub name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Directories {
/// Output directory for the build. Example: `"/home/mwu/Desktop/enso/dist/ide2"`.
pub output: PathBuf,
}
/// [`FileAssociation`] with additional fields for MIME type and ProgID.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtendedFileAssociation {
/// The [programmatic identifier](https://docs.microsoft.com/en-us/windows/win32/shell/fa-progids) of the file type. Example: `"Enso.Source"`.
pub prog_id: String,
/// MIME type of the file type. Example: `"text/plain"`.
pub mime_type: String,
/// Standard `electron-builder` file association configuration.
#[serde(flatten)]
pub base: FileAssociation,
}
impl Deref for ExtendedFileAssociation {
type Target = FileAssociation;
fn deref(&self) -> &Self::Target {
&self.base
}
}

View File

@ -0,0 +1,171 @@
//! A crate that provides utilities for building the Enso installer. It is used by:
//! * the build script (`./run`) - for [creating the installer bundle](bundler::bundle) (and
//! providing the necessary resources);
//! * the installer's and uninstaller's `build.rs` build scripts - for embedding the necessary
//! resources;
//! * the installer's and uninstaller's runtime code - for accessing the configuration. Note that
//! resource access in not part of this crate, as it is Windows-specific.
pub mod prelude {
pub use ide_ci::prelude::*;
}
pub mod bundler;
pub mod electron_builder;
pub mod payload;
use prelude::*;
use ide_ci::define_env_var;
use ide_ci::env::accessor::PathBufVariable;
use ide_ci::env::known::cargo::build::OUT_DIR;
use ide_ci::programs::cargo;
/// The filename stem of the installer executable — and the crate name.
pub const INSTALLER_NAME: &str = "enso-installer";
/// The filename stem of the uninstaller executable — and the crate name.
pub const UNINSTALLER_NAME: &str = "enso-uninstaller";
define_env_var! {
/// Path to the JSON file containing the Electron Builder configuration.
///
/// Unlike [`ENSO_INSTALL_ELECTRON_BUILDER_CONFIG`] this is a full dump of the configuration.
/// Provided by the enso-build to the `enso-install`'s `build.rs`.
ENSO_BUILD_ELECTRON_BUILDER_CONFIG, PathBuf;
/// Path to the JSON file containing the Electron Builder configuration.
///
/// This file is sanitized by the `enso-install`'s `build.rs` to contain only the necessary
/// information, as defined by the [`electron_builder::Config`] type.
///
/// In general, the installer/unistaller `build.rs` should set this variable, so it can be
/// embedded into the relevant binary.
ENSO_INSTALL_ELECTRON_BUILDER_CONFIG, PathBuf;
/// The path to the `tar.gz` archive containing the Enso IDE payload. Provided by the
/// enso-build to the enso-installer's `build.rs`, which embeds it into the installer's binary.
ENSO_INSTALL_ARCHIVE_PATH, PathBuf;
/// The path to the JSON file containing the metadata of the Enso IDE payload.
///
/// The metadata is modeled by the [`crate::payload::Metadata`] struct.
ENSO_INSTALL_METADATA_PATH, PathBuf;
}
/// Build-time script (build.rs) that retrieves the electron-builder configuration from the
/// file designated by the [`ENSO_BUILD_ELECTRON_BUILDER_CONFIG`] environment variable.
///
/// This function is intended to be used by the installer/uninstaller's `build.rs`.
pub fn electron_builder_config_from_env() -> Result<electron_builder::Config> {
let config_path = ENSO_BUILD_ELECTRON_BUILDER_CONFIG.get()?;
cargo::build::rerun_if_env_changed(ENSO_BUILD_ELECTRON_BUILDER_CONFIG);
cargo::build::rerun_if_file_changed(&config_path);
ide_ci::fs::read_json(config_path)
}
/// Location where the sanitized electron-builder configuration is placed.
///
/// Should be used only `build.rs`-time.
///
/// Runtime installer-dependent crates should embed the file.
pub fn sanitized_electron_builder_config_path() -> Result<PathBuf> {
Ok(OUT_DIR.get()?.join("electron-builder-config.json"))
}
/// Place electron-builder configuration under the output directory.
///
/// The file is taken from [`ENSO_BUILD_ELECTRON_BUILDER_CONFIG`], sanitized and placed under the
/// output directory. The resulting location will be exposed to the build through the environment
/// variable [`ENSO_INSTALL_ELECTRON_BUILDER_CONFIG`].
///
/// This function is intended to be used by the installer/uninstaller's `build.rs`.
///
/// Returns the parsed configuration.
pub fn sanitize_and_expose_electron_builder_config() -> Result<electron_builder::Config> {
// We sanitize by parsing into our structure (with only the fields we need) and then dumping
// back to JSON.
let config = electron_builder_config_from_env();
let out_config_path = sanitized_electron_builder_config_path()?;
if let Ok(config) = &config {
let json_text = serde_json::to_string_pretty(config)?;
ide_ci::fs::write_if_different(&out_config_path, json_text)?;
} else {
// We write dummy. This is to avoid the build script failing if the config is not available.
// This allows checking if the installer compiles without involving any electron-builder
// configuration.
ide_ci::fs::write_if_different(&out_config_path, "")?;
}
cargo::build::expose_env_var(ENSO_INSTALL_ELECTRON_BUILDER_CONFIG, out_config_path.as_str());
ENSO_INSTALL_ELECTRON_BUILDER_CONFIG.set(&out_config_path)?;
config
}
/// The resource (RCDATA) ID of the Enso installer payload.
///
/// The payload is a `tar.gz` archive containing the Enso IDE.
pub const INSTALLER_PAYLOAD_ID: &str = "INSTALLER_PAYLOAD";
/// Identifier for the Enso icon resource.
pub const ENSO_ICON_ID: &str = "ENSO_ICON_ID";
/// Resources that can be embedded into the binary.
#[derive(Debug, Clone, Copy)]
pub enum ResourceType {
/// Icon resource (`.ico` file).
///
/// Windows will automatically use the first embedded icon as the icon for the binary.
Icon,
/// Arbitrary binary data.
Binary,
}
impl Display for ResourceType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ResourceType::Icon => write!(f, "ICON"),
ResourceType::Binary => write!(f, "RCDATA"),
}
}
}
/// Embeds a file as a resource into the binary.
///
/// This function is intended to be used by the installer/uninstaller's `build.rs`.
pub fn embed_resource_from_file(
resource_id: &str,
resource_type: ResourceType,
resource_path: &Path,
) -> Result {
let rc_file = OUT_DIR.get().unwrap().join(resource_id).with_extension("rc");
cargo::build::rerun_if_env_changed(rc_file.as_str());
// We need to either replace backslashes with forward slashes or escape them, as RC file is
// kinda-compiled. The former is easier.
let sanitized_path = resource_path.to_str().unwrap().replace('\\', "/");
cargo::build::rerun_if_file_changed(&sanitized_path);
let contents = format!(r#"{resource_id} {resource_type} "{sanitized_path}""#);
ide_ci::fs::write_if_different(&rc_file, contents)?;
embed_resource::compile(&rc_file, embed_resource::NONE);
Ok(())
}
/// Embeds a resource using a path from an environment variable.
///
/// It should be preferred over [`embed_resource_from_file`] when the path to the resource is stored
/// in an environment variable. The function will automatically rerun the build script if the
/// environment variable changes.
///
/// This function is intended to be used by the installer/uninstaller's `build.rs`.
pub fn embed_resource_from_env(
resource_id: &str,
resource_type: ResourceType,
env_var: &PathBufVariable,
) -> Result {
cargo::build::rerun_if_env_changed(env_var);
embed_resource_from_file(resource_id, resource_type, &env_var.get()?)
}

View File

@ -0,0 +1,50 @@
//! Installer has compiled-in binary payload and metadata about it.
use crate::prelude::*;
/// Information about the archive payload of the installer.
///
/// This information is used to display progress information to the user. While it could be
/// generated at runtime by inspecting the archive, it is more efficient to generate it at build
/// time and embed it into the installer binary.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct Metadata {
/// Number of files in the archive.
pub total_files: u64,
/// Total size of the extracted files in bytes.
pub total_bytes: u64,
}
impl Metadata {
/// Scans the given directory and calculates the payload information.
pub fn from_directory(unpacked_directory: &Path) -> Result<Self> {
let mut total_files = 0;
let mut total_bytes = 0;
for entry in walkdir::WalkDir::new(unpacked_directory) {
let entry = entry?;
let metadata = entry.metadata()?;
total_files += 1;
// We treat directories as empty files.
total_bytes += if metadata.is_dir() { 0 } else { metadata.len() };
}
Ok(Self { total_files, total_bytes })
}
}
/// Take the electron-builder output and prepare the payload files for the installer.
///
/// These files need to be provided to the installer at build time, so they can be embedded into
/// the installer binary
pub async fn prepare_payload(
unpacked_directory: &Path,
output_archive: &Path,
output_metadata: &Path,
) -> Result {
let metadata = Metadata::from_directory(unpacked_directory)?;
ide_ci::archive::compress_directory_contents(&output_archive, &unpacked_directory).await?;
let metadata_json = serde_json::to_string_pretty(&metadata)?;
ide_ci::fs::write_if_different(output_metadata, metadata_json)?;
Ok(())
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="app"
type="win32"
/>
<asmv3:application>
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">
<activeCodePage>UTF-8</activeCodePage>
</asmv3:windowsSettings>
</asmv3:application>
<asmv3:application>
<asmv3:windowsSettings xmlns:ws2="http://schemas.microsoft.com/SMI/2016/WindowsSettings">
<ws2:longPathAware>true</ws2:longPathAware>
</asmv3:windowsSettings>
</asmv3:application>
<dependency>
<dependentAssembly>
<!-- Needed for GetWindowSubclass entry point, see https://github.com/gabdube/native-windows-gui/issues/251. -->
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

View File

@ -0,0 +1,35 @@
[package]
name = "enso-installer"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
byte-unit = { workspace = true }
chrono = { workspace = true }
enso-build-base = { path = "../../base" }
enso-install = { path = ".." }
enso-install-config = { path = "../config" }
flate2 = { workspace = true }
ide-ci = { path = "../../ci_utils" }
serde_json = { workspace = true }
sysinfo = { workspace = true }
tar = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
windows = { workspace = true }
winapi = "0.3"
[target.'cfg(windows)'.dependencies]
native-windows-gui = { workspace = true }
[build-dependencies]
embed-resource = "2.4.0"
ide-ci = { path = "../../ci_utils" }
enso-install-config = { path = "../config" }
flate2 = { workspace = true }
tar = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,46 @@
//! The build script of the installer.
//!
//! It is used to compile in the binary payload and metadata about it into the installer binary.
//!
//! Note that other resources (icons, manifests, version information) are already included in the
//! installer binary by the `enso-install` library crate.
use ide_ci::prelude::*;
use enso_install_config::sanitize_and_expose_electron_builder_config;
use enso_install_config::ENSO_INSTALL_ARCHIVE_PATH;
use enso_install_config::ENSO_INSTALL_METADATA_PATH;
use enso_install_config::INSTALLER_PAYLOAD_ID;
use ide_ci::env::known::cargo::build::OUT_DIR;
use ide_ci::programs::cargo;
fn main() {
let rc_file = OUT_DIR.get().unwrap().join("archive.rc");
cargo::build::rerun_if_file_changed(&rc_file);
cargo::build::rerun_if_env_changed(ENSO_INSTALL_ARCHIVE_PATH);
if let Ok(archive) = ENSO_INSTALL_ARCHIVE_PATH.get() {
cargo::build::rerun_if_file_changed(archive.as_str());
// We need to either replace backslashes with forward slashes or escape them, as RC file is
// kinda-compiled. The former is easier.
let sanitized_path = archive.as_str().replace('\\', "/");
let contents = format!(r#"{INSTALLER_PAYLOAD_ID} RCDATA "{sanitized_path}""#);
ide_ci::fs::write_if_different(&rc_file, contents).unwrap();
embed_resource::compile(&rc_file, embed_resource::NONE);
} else {
println!("cargo:warning={ENSO_INSTALL_ARCHIVE_PATH} is not set, the installer will fail at runtime.");
}
cargo::build::rerun_if_env_changed(ENSO_INSTALL_METADATA_PATH);
if !ENSO_INSTALL_METADATA_PATH.is_set() {
println!("cargo:warning={ENSO_INSTALL_METADATA_PATH} is not set, the installer will fail at runtime.");
let placeholder_path = OUT_DIR.get().unwrap().join("metadata.json");
ide_ci::fs::write_if_different(&placeholder_path, "{}").unwrap();
// Set env for the crate.
cargo::build::expose_env_var(ENSO_INSTALL_METADATA_PATH, placeholder_path.as_str());
}
let _ = sanitize_and_expose_electron_builder_config();
}

View File

@ -0,0 +1,56 @@
//! This crate implements the Windows installer for the Enso IDE.
// === Features ===
#![feature(lazy_cell)]
use enso_install::prelude::*;
use enso_install::access_built_time_env;
// ==============
// === Export ===
// ==============
#[cfg(windows)]
pub mod win;
pub use enso_install::prelude;
/// Update message sent by the installer logic thread to the UI.
///
/// These are used to communicate the progress of the installation process to the user.
#[derive(Debug)]
pub enum InstallerUpdate {
/// Update the overall progress of the installation. The value is a number between 0 and 1.
Progress(f64),
/// Describe the current stage of the installation process.
///
/// The value will be displayed to the user, so it should be a human-readable string.
Stage(String),
/// The installation has finished.
///
/// If the Result is an error, the installation has failed and the error will be displayed to
/// the user.
Finished(Result),
}
/// Handle to the compiled-in installer payload.
#[derive(Copy, Clone, Debug)]
pub struct Payload {
/// The binary data of the payload.
pub data: &'static [u8],
/// The metadata of the payload.
pub metadata: &'static enso_install_config::payload::Metadata,
}
/// Retrieve the compiled-in installer payload metadata.
pub fn access_payload_metadata() -> &'static enso_install_config::payload::Metadata {
access_built_time_env!(
ENSO_INSTALL_METADATA_PATH,
enso_install_config::payload::Metadata,
"payload metadata"
)
}

View File

@ -0,0 +1,23 @@
#![windows_subsystem = "windows"] // Do not display a console window when running the installer.
use enso_installer::prelude::*;
#[cfg(windows)]
fn main() -> Result {
enso_installer::win::main()
}
#[cfg(not(windows))]
fn main() -> Result {
bail!("This installer is only supported on Windows.")
}
#[cfg(test)]
mod tests {
#[test]
fn installer_name_matches() {
assert_eq!(enso_install_config::INSTALLER_NAME, env!("CARGO_PKG_NAME"));
}
}

View File

@ -0,0 +1,80 @@
use ide_ci::prelude::*;
use crate::access_payload_metadata;
use crate::win::config::Config;
use crate::win::logic::install_with_updates;
use crate::Payload;
use enso_install::win::local_app_data;
// ==============
// === Export ===
// ==============
pub mod app;
pub mod config;
pub mod logic;
/// Retrieve the compiled-in installer payload with metadata.
pub fn access_payload() -> Result<Payload> {
Ok(Payload {
data: enso_install::win::get_installer_payload()?,
metadata: access_payload_metadata(),
})
}
/// Spawn a thread that will install the Enso IDE at the given location.
pub fn spawn_installer_thread(
install_location: impl AsRef<Path>,
payload: Payload,
config: Config,
) -> Result<(std::thread::JoinHandle<Result>, std::sync::mpsc::Receiver<crate::InstallerUpdate>)> {
let install_location = install_location.as_ref().to_path_buf();
let (sender, receiver) = std::sync::mpsc::channel();
let handle = std::thread::Builder::new()
.name("Installer Logic".into())
.spawn(move || {
let result = install_with_updates(&install_location, payload, &config, &sender);
if let Err(err) = result {
let msg = format!("Installation failed: {err:?}.");
let _ = sender.send(crate::InstallerUpdate::Finished(Result::Err(err)));
bail!(msg);
}
Ok(())
})
.context("Failed to spawn the installer logic thread.")?;
Ok((handle, receiver))
}
/// Get the default installation directory.
pub fn get_install_dir(pretty_name: &str) -> Result<PathBuf> {
let programs_dir = enso_install::win::user_program_files()
.or_else(|e| {
warn!("Failed to get the user's program files directory: {e:?}");
// The Windows might refuse to provide the user's program files directory in some cases,
// like brand-new user accounts that don't have the directory created yet.
// Thus, we fall back to the default location, as documented in:
// https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
Result::Ok(local_app_data()?.join("Programs"))
})
.context("Failed to get the user's program files directory")?;
Ok(programs_dir.join(pretty_name))
}
/// The installer's entry point.
pub fn main() -> Result {
// Note: logging will be set up by the `InstallerApp`. It needs to be responsible for this to be
// able to show the log file to the user.
let app = app::InstallerApp::new()?;
app.run();
if let Some(result) = app.result.take() {
info!("Installation finished with result: {result:?}",);
result
} else {
bail!("Installation finished without setting the result.")
}
}

View File

@ -0,0 +1,233 @@
use crate::prelude::*;
use native_windows_gui::NativeUi;
use std::sync::mpsc::Receiver;
extern crate native_windows_gui as nwg;
use crate::win::access_payload;
use crate::win::config;
use crate::win::get_install_dir;
use crate::win::spawn_installer_thread;
use crate::InstallerUpdate;
pub mod ui;
/// The number of ticks the progress is divided into.
pub const PROGRESS_BAR_TICKS: u32 = 1000;
/// The installer's UI application.
///
/// This struct holds all the UI elements and the logic to drive the installation process.
/// The design follows the pattern recommended by the `native-windows-gui` library.
// Note: we do not use the derive API, as:
// - it does not support the `FlexboxLayout`,
// - is generally more trouble than it's worth.
#[derive(Default)]
#[allow(missing_debug_implementations)]
pub struct InstallerApp {
/// Path to the log file. We use it to point the user to the log file in case of an error.
pub logfile: PathBuf,
/// Title used by the main window and some of the dialogs.
pub window_title: String,
/// The main window of the installer.
pub window: nwg::Window,
/// The main window's layout. Column with the top layout and the progress bar.
pub layout: nwg::FlexboxLayout,
/// Row with the Enso icon and the label.
pub top_layout: nwg::FlexboxLayout,
/// The Enso icon (displayed left to the label).
pub image: nwg::ImageFrame,
/// The label that shows the current stage of the installation.
pub label: nwg::Label,
/// The progress bar that shows the overall installation progress.
pub progress_bar: nwg::ProgressBar,
/// Handle to the embedded resources, such as the [`InstallerApp::enso_icon`].
pub embed: nwg::EmbedResource,
/// The icon handle that is displayed by the [`InstallerApp::image`].
pub enso_icon: nwg::Icon,
/// The timer we use to drive the [`InstallerApp::tick`] method.
///
/// Note that despite the name, the `AnimationTimer` is recommended to be used as a total
/// replacement for the `Timer`.
pub timer: nwg::AnimationTimer,
/// Facilitates communication from the "installer backend" thread to the UI thread.
pub backend_receiver: std::cell::RefCell<Option<Receiver<InstallerUpdate>>>,
/// Handle to the thread that runs the installation logic.
pub backend_thread: std::cell::RefCell<Option<std::thread::JoinHandle<Result>>>,
/// Result of the installation process.
///
/// This should be filled by the UI thread before breaking the event loop.
pub result: std::cell::RefCell<Option<Result>>,
/// Path to the installed application executable.
///
/// After a successful installation, this is used to launch the application.
pub installed_app: std::cell::RefCell<PathBuf>,
}
impl InstallerApp {
/// Create a new instance of the installer application.
///
/// This includes setting up logging.
#[allow(clippy::new_ret_no_self)] // This follow the pattern advertised by the NWG crate.
pub fn new() -> Result<ui::Ui> {
let window_title =
format!("{} installer", enso_install::sanitized_electron_builder_config().product_name);
let dialog_title = window_title.clone();
let logfile =
enso_install::win::ui::setup_logging_or_fatal(env!("CARGO_PKG_NAME"), &window_title);
let logfile_copy = logfile.clone();
// We intercept all the errors that can occur during the initialization of the UI.
let result = move || -> Result<ui::Ui> {
ui::init_global()?;
let app = InstallerApp { logfile, window_title, ..Default::default() };
InstallerApp::build_ui(app).context("Failed to build UI")
}();
if let Err(err) = &result {
// We use "error" rather than "fatal", because "fatal" panics as soon as the error
// dialog is closed. And we still want to open the logs.
ui::error_message(&dialog_title, &format!("Installer failed to start: {err:?}"));
let _ = ide_ci::programs::explorer::show_selected(logfile_copy);
}
result
}
/// Runs the installation.
///
/// # Panics
/// In case of failure, shows an error message and opens the log file - then panics.
pub fn run(&self) {
let result = || -> Result {
let config = config::fill_config()?;
let install_dir = get_install_dir(&config.pretty_name)?;
let payload = access_payload()?;
let (handle, receiver) = spawn_installer_thread(&install_dir, payload, config.clone())?;
self.backend_receiver.borrow_mut().replace(receiver);
self.backend_thread.borrow_mut().replace(handle);
*self.installed_app.borrow_mut() = install_dir.join(&config.executable_filename);
Ok(())
}()
.context("Failed to start installation.");
if let Err(err) = result {
self.fail_installation(err);
} else {
debug!("Starting event loop");
nwg::dispatch_thread_events();
debug!("Event loop finished");
}
}
/// Handle a single event dispatched by the event loop.
pub fn handle_ui_event(
&self,
event: nwg::Event,
evt_data: nwg::EventData,
handle: nwg::ControlHandle,
) {
match event {
nwg::Event::OnTimerTick =>
if handle == self.timer {
self.tick();
},
nwg::Event::OnWindowClose => {
// Prevent manual closing of the window. Installation should not be interrupted.
if let nwg::EventData::OnWindowClose(close_data) = evt_data {
close_data.close(false);
}
}
_ => {}
}
}
/// Stop the event loop due to an installation error.
///
/// Shows the dialog with the error message and points the user to the log file.
pub fn fail_installation(&self, error: anyhow::Error) {
let msg = format!("{error:?}");
let _ = self.result.borrow_mut().replace(Result::Err(error));
self.label.set_text("Installation failed.");
self.progress_bar.set_state(nwg::ProgressBarState::Error);
self.progress_bar.set_pos(100);
error!(msg);
info!("Showing modal error message.");
nwg::modal_error_message(&self.window, &self.window_title, &msg);
info!("Stopping the event loop due to an error.");
nwg::stop_thread_dispatch();
info!("Showing the log file in the file explorer.");
let _ = ide_ci::programs::explorer::show_selected(&self.logfile);
}
/// Mark installation as successful and stop the event loop.
pub fn succeed_installation(&self) {
info!("Installation completed successfully.");
let _ = self.result.borrow_mut().replace(Ok(()));
self.label.set_text("Installation completed successfully.");
nwg::stop_thread_dispatch();
self.window.close();
let installed_app = &self.installed_app.borrow();
info!("Starting the installed application: {}", installed_app.display());
let _ = Command::new(&**installed_app).spawn().inspect_err(|err| {
// We won't stop the whole world if we can't start the installed application.
// Still, we should leave some trace of what happened.
error!("Failed to start the installed application: {err:?}");
});
}
/// Method called by the timer to update the UI.
///
/// It pulls updates from the installer backend and updates the UI accordingly.
pub fn tick(&self) {
let Ok(installer_state) = self.backend_receiver.try_borrow_mut() else {
// If the receiver is already borrowed, it means that we have re-entered this method
// from the failure dialog event loop. In such case we do nothing.
return;
};
if let Some(receiver) = installer_state.deref() {
loop {
if self.result.borrow().is_some() {
// If the installation has already finished, we don't need to do anything.
return;
}
match receiver.try_recv() {
Ok(update) => {
info!("Update: {:?}", update);
match update {
InstallerUpdate::Progress(progress) => {
let new_ticks = (progress * PROGRESS_BAR_TICKS as f64) as u32;
self.progress_bar.set_pos(new_ticks);
}
InstallerUpdate::Stage(stage) => {
self.label.set_text(&stage);
}
InstallerUpdate::Finished(result) => {
if let Err(err) = result {
self.fail_installation(err);
} else {
self.succeed_installation();
}
break;
}
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// We expect to receive `InstallerUpdate::Finished` before the channel is
// closed. Otherwise, it means that the backend thread has crashed.
let err =
anyhow!("The installer backend thread has unexpectedly disconnected.");
self.fail_installation(err);
break;
}
}
}
}
}
}

View File

@ -0,0 +1,151 @@
//! The UI setup for the installer application.
//!
//! This is roughly what `native-windows-derive` would generate.
use crate::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
extern crate native_windows_gui as nwg;
use crate::win::app::InstallerApp;
use crate::win::app::PROGRESS_BAR_TICKS;
use enso_install_config::ENSO_ICON_ID;
use nwg::stretch::geometry::Size;
use nwg::stretch::style::Dimension;
use nwg::stretch::style::FlexDirection;
use nwg::NativeUi;
/// Size for the Enso icon displayed in the window next to the text label.
pub const ICON_SIZE: u32 = 32;
/// Default font used in the application.
///
/// Segoe UI is the default font in Windows since Vista and we can reasonably expect it to be
/// available.
pub const DEFAULT_FONT: &str = "Segoe UI";
pub struct Ui {
/// Inner application data, that is shared with the event callbacks.
inner: Rc<InstallerApp>,
/// Main events handler handle - so we can unbind it when the UI is dropped.
default_handler: RefCell<Option<nwg::EventHandler>>,
}
impl Debug for Ui {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ui").finish()
}
}
impl NativeUi<Ui> for InstallerApp {
fn build_ui(mut data: InstallerApp) -> std::result::Result<Ui, nwg::NwgError> {
nwg::EmbedResource::builder().build(&mut data.embed)?;
nwg::Icon::builder()
.source_embed(Some(&data.embed))
.source_embed_str(Some(ENSO_ICON_ID))
.strict(false)
.size(Some((ICON_SIZE, ICON_SIZE)))
.build(&mut data.enso_icon)?;
nwg::Window::builder()
.title(&data.window_title)
.flags(nwg::WindowFlags::WINDOW | nwg::WindowFlags::VISIBLE)
.icon(Some(&data.enso_icon))
.size((400, 100))
.build(&mut data.window)?;
nwg::ImageFrame::builder()
.icon(Some(&data.enso_icon))
.size((ICON_SIZE as i32, ICON_SIZE as i32))
.parent(&data.window)
.build(&mut data.image)?;
nwg::Label::builder()
.text("Preparing the installer...")
.parent(&data.window)
.build(&mut data.label)?;
nwg::ProgressBar::builder()
.step(1)
.range(0..PROGRESS_BAR_TICKS)
.parent(&data.window)
.build(&mut data.progress_bar)?;
nwg::AnimationTimer::builder()
.parent(&data.window)
.interval(std::time::Duration::from_millis(100))
.active(true)
.build(&mut data.timer)?;
let inner = Rc::new(data);
let ui = Ui { inner: inner.clone(), default_handler: Default::default() };
let evt_ui = Rc::downgrade(&inner);
let handle_events = move |evt, evt_data, handle| {
if let Some(evt_ui) = evt_ui.upgrade() {
evt_ui.handle_ui_event(evt, evt_data, handle);
}
};
let event_handler = nwg::full_bind_event_handler(&ui.window.handle, handle_events);
*ui.default_handler.borrow_mut() = Some(event_handler);
nwg::FlexboxLayout::builder()
.parent(&ui.window)
.flex_direction(FlexDirection::Row)
.child(&ui.image)
.child_size(Size {
width: Dimension::Points(ICON_SIZE as f32),
height: Dimension::Points(ICON_SIZE as f32),
})
.child(&ui.label)
.child_flex_grow(1.0)
.child_size(Size { width: Dimension::Auto, height: Dimension::Auto })
.build_partial(&ui.top_layout)?;
nwg::FlexboxLayout::builder()
.parent(&ui.window)
.flex_direction(FlexDirection::Column)
.child_layout(&ui.top_layout)
.child_size(Size { width: Dimension::Percent(1.0), height: Dimension::Auto })
.child(&ui.progress_bar)
.child_size(Size { width: Dimension::Percent(0.9), height: Dimension::Points(16.0) })
.build(&ui.layout)?;
Ok(ui)
}
}
impl Drop for Ui {
/// To make sure that everything is freed without issues, the default handler must be
/// unbound.
fn drop(&mut self) {
let handler = self.default_handler.borrow();
if let Some(handler) = handler.as_ref() {
nwg::unbind_event_handler(handler);
}
}
}
impl Deref for Ui {
type Target = InstallerApp;
fn deref(&self) -> &InstallerApp {
&self.inner
}
}
/// Display an error message to the user and log it.
pub fn error_message(title: &str, message: &str) {
error!("{message}");
nwg::error_message(title, message);
}
/// Init the global state of the `native-windows-gui` library.
pub fn init_global() -> Result {
nwg::init().context("Failed to init Native Windows GUI")?;
let mut font = nwg::Font::default();
nwg::FontBuilder::new()
.family(DEFAULT_FONT)
.size(16)
.build(&mut font)
.context("Failed to create default font")?;
nwg::Font::set_global_default(Some(font));
Ok(())
}

View File

@ -0,0 +1,88 @@
//! Information defining the installer's behavior.
use crate::prelude::*;
/// All the configuration and constants needed to build the installer.
#[derive(Clone, Debug)]
pub struct Config {
/// E.g. `Enso.exe`.
pub executable_filename: PathBuf,
/// E.g. `New Byte Order sp. z o.o.`.
pub publisher: String,
/// E.g. `Enso`.
pub pretty_name: String,
/// E.g. `Enso`.
///
/// Used for entries in the Start Menu and Desktop.
pub shortcut_name: String,
/// The name of the registry key where uninstall information is stored, e.g. `Enso`.
///
/// The key is located under
/// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall`.
pub uninstall_key: String,
/// Version of the application.
pub version: Version,
/// The URL protocols that will be registered for the application, e.g. `enso`.
pub url_protocols: Vec<String>,
/// File associations.
pub file_associations:
Vec<(enso_install::win::prog_id::FileType, enso_install::win::prog_id::FileExtension)>,
}
/// Generate the `Config` for the installer from the compiled-in Electron Builder configuration.
pub fn fill_config() -> Result<Config> {
let electron = enso_install::sanitized_electron_builder_config();
let executable_filename = electron.product_name.with_executable_extension();
let publisher = electron.extra_metadata.installer.publisher.clone();
let pretty_name = electron.product_name.clone();
let shortcut_name = pretty_name.clone();
let uninstall_key = pretty_name.clone();
let version = electron.extra_metadata.version.clone();
let url_protocols = electron.protocols.iter().flat_map(|p| &p.schemes).map_into().collect();
let file_associations = electron
.extra_metadata
.installer
.file_associations
.iter()
.map(|file_association| {
let prog_id = file_association.prog_id.clone();
let extension = file_association.ext.clone();
let mime_type = file_association.mime_type.clone();
let perceived_type =
enso_install::win::prog_id::PerceivedType::from_mime_type(&mime_type)?;
let file_type = enso_install::win::prog_id::FileType {
application_path: executable_filename.clone(),
prog_id: prog_id.clone(),
friendly_name: file_association.name.clone(),
info_tip: file_association.name.clone(),
};
let file_extension = enso_install::win::prog_id::FileExtension {
extension,
prog_id,
mime_type,
perceived_type,
};
Result::Ok((file_type, file_extension))
})
.try_collect()?;
Ok(Config {
executable_filename,
publisher,
pretty_name,
shortcut_name,
uninstall_key,
version,
url_protocols,
file_associations,
})
}

View File

@ -0,0 +1,264 @@
//! Code that performs the installation.
use crate::prelude::*;
use crate::win::config::Config;
use crate::Payload;
use enso_install_config::UNINSTALLER_NAME;
use flate2::read::GzDecoder;
/// Register file extensions and their associations in the Windows registry.
pub fn register_file_associations(
file_associations: &[(
enso_install::win::prog_id::FileType,
enso_install::win::prog_id::FileExtension,
)],
) -> Result {
for (file_type, file_extension) in file_associations {
info!("Registering file extension '{}'.", file_extension.extension);
file_extension.register()?;
info!("Registering file type '{}'.", file_type.prog_id);
file_type.register()?;
}
info!("Refreshing file associations in the shell.");
enso_install::win::refresh_file_associations();
Ok(())
}
/// Set application as the default handler for the given URL protocol, e.g. `enso://`.
///
/// This is necessary for the deep linking to work.
pub fn register_url_protocol(executable_path: &Path, protocol: &str) -> Result {
let info = enso_install::win::prog_id::ProtocolInfo::new(protocol, executable_path);
info.register()
}
/// Register the uninstaller in the Windows registry.
pub fn register_uninstaller(
config: &Config,
install_directory: &Path,
uninstaller_path: &Path,
installation_size_bytes: u64,
) -> Result {
let mut uninstall_info = enso_install::win::uninstall::UninstallInfo::new(
&config.pretty_name,
uninstaller_path.as_str(),
);
uninstall_info.install_location = Some(install_directory.display().to_string());
uninstall_info.install_date = Some(chrono::Local::now().to_string());
uninstall_info.publisher = Some(config.publisher.clone());
uninstall_info.display_icon = Some(uninstaller_path.display().to_string());
uninstall_info.display_version = Some(config.version.to_string());
uninstall_info.estimated_size_kib = Some((installation_size_bytes / 1024) as u32);
uninstall_info.write_to_registry(&config.uninstall_key)?;
Ok(())
}
/// Install Enso.
///
/// The archive payload is binary data of the tar.gz archive that contains the Enso app.
pub fn install_with_updates(
install_location: &Path,
payload: Payload,
config: &Config,
sender: &std::sync::mpsc::Sender<crate::InstallerUpdate>,
) -> Result {
let send = |update| {
info!("Sending update: {update:?}");
let _ = sender.send(update);
};
let report_progress = |progress| {
send(crate::InstallerUpdate::Progress(progress));
};
macro_rules! stage_at {
($progress:tt, $($arg:tt)*) => {
send(crate::InstallerUpdate::Stage(format!($($arg)*)));
send(crate::InstallerUpdate::Progress($progress));
};
}
macro_rules! bail {
($($arg:tt)*) => {{
let msg = format!($($arg)*);
let err = anyhow::Error::msg(msg.clone());
send(crate::InstallerUpdate::Finished(Err(err)));
anyhow::bail!("{msg}");
}};
}
// Only one installer / uninstaller can run at a time.
let _guard = enso_install::locked_installation_lock()?;
let enso_install_config::payload::Metadata { total_files, total_bytes } = *payload.metadata;
stage_at!(0.00, "Checking disk space.");
// TODO? A potential improvement would be to take account for previous installation size when
// performing the in-place update. Then the needed space would be the difference between
// the new and the old installation size.
let per_file_overhead = 4096; // The default allocation unit size on NTFS.
let space_required = total_bytes + (total_files * per_file_overhead);
match check_disk_space(install_location, space_required) {
Ok(Some(msg)) => bail!("{msg}"),
Ok(None) => {} // Ok, enough space.
Err(err) => {
// We don't know, so let's just log the warning and try to carry on.
warn!("Failed to check disk space: {err:?}");
}
}
stage_at!(0.01, "Checking for running processes.");
match enso_install::is_already_running(install_location, &[]) {
Ok(Some(msg)) => bail!("{msg}"),
Ok(None) => {} // Ok, no colliding processes.
Err(err) => {
// We don't know, so let's just log the warning and try to carry on.
warn!("Failed to check for running processes: {err:?}");
}
}
stage_at!(0.03, "Removing old installation files (if present).");
ide_ci::fs::reset_dir(install_location)?;
let executable_location = install_location.join(&config.executable_filename);
// Extract the files.
let decoder = GzDecoder::new(payload.data);
let archive = tar::Archive::new(decoder);
let extraction_progress_start = 0.06;
let extraction_progress_step = 0.82;
let mut files_extracted = 0;
let mut bytes_extracted = 0;
stage_at!(extraction_progress_start, "Extracting files.");
let mut bytes_being_extracted = 0;
let to_our_path = |entry: &tar::Entry<GzDecoder<&[u8]>>| -> Option<PathBuf> {
// If we receive a new file, update the counters.
files_extracted += 1;
bytes_extracted += bytes_being_extracted;
bytes_being_extracted = entry.header().size().unwrap_or(0);
let files_ratio = (files_extracted as f64 / total_files as f64).min(1.0);
let bytes_ratio = (bytes_extracted as f64 / total_bytes as f64).min(1.0);
let extraction_progresss = (files_ratio + bytes_ratio) / 2.0;
let progress = extraction_progress_start + extraction_progress_step * extraction_progresss;
trace!("files_extracted: {files_extracted}/{total_files}, bytes_extracted: {bytes_extracted}/{total_bytes}, extraction_progresss: {extraction_progresss}, progress: {progress}");
report_progress(progress);
Some(install_location.join(entry.path().ok()?))
};
ide_ci::archive::tar::extract_files_sync(archive, to_our_path)?;
// As we've been incrementing this values when extracting the next file, we need to cover the
// last file.
bytes_extracted += bytes_being_extracted;
let post_extraction_progress = extraction_progress_start + extraction_progress_step;
stage_at!(post_extraction_progress, "Registering file types.");
register_file_associations(&config.file_associations)?;
for protocol in &config.url_protocols {
stage_at!(0.90, "Registering URL protocol '{protocol}'.");
register_url_protocol(&executable_location, protocol)?;
}
stage_at!(0.92, "Registering the application path.");
let app_paths_info = enso_install::win::app_paths::AppPathInfo::new(&executable_location);
app_paths_info.write_to_registry()?;
stage_at!(0.94, "Registering the uninstaller.");
register_uninstaller(
config,
install_location,
&install_location.join(UNINSTALLER_NAME.with_executable_extension()),
bytes_extracted,
)?;
stage_at!(0.96, "Creating Start Menu entry.");
enso_install::win::shortcut::Location::Menu
.create_shortcut(&config.shortcut_name, &executable_location)?;
stage_at!(0.98, "Creating Desktop shortcut.");
enso_install::win::shortcut::Location::Desktop
.create_shortcut(&config.shortcut_name, &executable_location)?;
stage_at!(1.0, "Installation complete.");
send(crate::InstallerUpdate::Finished(Ok(())));
Ok(())
}
/// Check if there is enough disk space to install the application.
///
/// If the space is insufficient, returns an error message. If the space is sufficient, returns
/// `None`. If the necessary information cannot be obtained, returns an error.
///
/// Note that usually it is better to ignore the error than to fail the installation process. Not
/// knowing that the disk space is sufficient is not meaning that it is insufficient.
/// For example, we might be targetting a network path for which we cannot obtain the disk space.
pub fn check_disk_space(
installation_directory: &Path,
bytes_required: u64,
) -> Result<Option<String>> {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
// This should yield an absolute path, prefixed with the drive label.
let path = installation_directory
.canonicalize()
// We use absolutize as a fallback, because canonicalize fails for non-existent paths. We
// attempt to use canonicalize first, because it resolves symlinks.
.or_else(|_| installation_directory.absolutize().map(PathBuf::from))?;
// We need to remove the verbatim prefix (that canonicalize likes to add) in order to match the
// disk list mount points format.
let path = path.without_verbatim_prefix();
let disk = disks
.into_iter()
.find(|disk| path.starts_with(disk.mount_point()))
.context("No disk information found for the installation directory.")?;
let required_space = byte_unit::Byte::from_u64(bytes_required);
let free_space = byte_unit::Byte::from_u64(disk.available_space());
if free_space < required_space {
let msg = format!(
"Not enough disk space on {} to install. Required: {:.2}, available: {:.2}.",
disk.mount_point().display(),
required_space.get_appropriate_unit(byte_unit::UnitType::Binary),
free_space.get_appropriate_unit(byte_unit::UnitType::Binary)
);
return Ok(Some(msg));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
/// Test to manually check the generated disk space message.
fn check_disk_space_test() -> Result {
let my_path = ide_ci::env::current_dir()?;
let r = check_disk_space(&my_path, 10_000_000_000_000);
let _ = dbg!(r);
Ok(())
}
#[test]
#[ignore]
/// Test to manually check the running processes.
fn is_already_running_test() -> Result {
setup_logging().ok();
let install_path = crate::win::get_install_dir("Enso")?;
let r = enso_install::is_already_running(&install_path, &[])?;
dbg!(r);
Ok(())
}
}

176
build/install/src/lib.rs Normal file
View File

@ -0,0 +1,176 @@
//! This crate is linked in both by the installer and the uninstaller.
// === Features ===
#![feature(lazy_cell)]
pub mod prelude {
pub use ide_ci::prelude::*;
#[cfg(windows)]
pub use winreg::types::ToRegValue;
#[cfg(windows)]
pub use winreg::RegKey;
#[cfg(windows)]
pub use winreg::RegValue;
}
use enso_install_config::electron_builder;
use ide_ci::log::file_log_layer;
use ide_ci::log::stderr_log_layer;
use ide_ci::log::GlobalFilteringLayer;
use prelude::*;
use sysinfo::Pid;
#[cfg(windows)]
pub mod win;
/// A macro for accessing compiled-in JSON data.
///
/// # Parameters
/// - `$env` - the name of the environment variable that contains the path to the JSON file.
/// - `$typename` - the type of the data that is stored in the JSON file.
/// - `$pretty_name` - a human-readable name of the data type that will be used in error messages.
#[macro_export]
macro_rules! access_built_time_env {
($env:ident, $typename:ty, $pretty_name:expr) => {
{
static DATA: std::sync::LazyLock<$typename> = std::sync::LazyLock::new(|| {
let crate_name = env!("CARGO_PKG_NAME");
let pretty = $pretty_name;
let path = env!(stringify!($env));
let data = include_str!(env!(stringify!($env)));
if path.is_empty() {
panic!("The path to the {pretty} is empty. The {crate_name} was built without `{}` environment variable set.", stringify!($env));
}
if data.is_empty() {
panic!("The {pretty} file is empty. Likely the stub was provided to enable compilation. Investigate the build logs warnings.");
}
serde_json::from_str(data).expect(&format!("Failed to parse the {pretty}."))
});
&DATA
}
};
}
/// Access compiled-in `electron builder`-based configuration.
///
/// # Panics
///
/// This function will panic if the path to the configuration was not set during the build process,
/// or if the configuration was invalid.
pub fn sanitized_electron_builder_config() -> &'static electron_builder::Config {
access_built_time_env!(
ENSO_INSTALL_ELECTRON_BUILDER_CONFIG,
electron_builder::Config,
"Electron Builder configuration"
)
}
/// The name of the Windows registry key where uninstall information is stored.
///
/// The key is located under
/// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall`.
pub fn uninstall_key() -> &'static str {
&sanitized_electron_builder_config().product_name
}
/// The full filename (not path!) of the application executable, including the extension.
pub fn executable_filename() -> PathBuf {
sanitized_electron_builder_config().product_name.with_executable_extension()
}
/// The name of the shortcut.
///
/// Used on Windows for Start Menu and Desktop shortcuts.
pub fn shortcut_name() -> &'static str {
&sanitized_electron_builder_config().product_name
}
/// Acquire a named file lock.
///
/// The lock is to be used to ensure that only one instance of the (un)installer is running at a
/// time.
pub fn installation_file_lock() -> Result<named_lock::NamedLock> {
let name = env!("CARGO_PKG_NAME");
let lock = named_lock::NamedLock::create(name)
.with_context(|| format!("Failed to create a named file lock for '{name}'."))?;
Ok(lock)
}
/// Acquire the named file lock and return the guard.
pub fn locked_installation_lock() -> Result<named_lock::NamedLockGuard> {
installation_file_lock()?.try_lock().with_context(|| "Failed to acquire the named file lock. Is there another instance of the installer or uninstaller running?")
}
/// Check if the application is already running.
///
/// If there is any process running from the installation directory, returns an error message as
/// `Some`. If there are no such processes, returns `None`. If the necessary information cannot be
/// obtained, returns an error.
///
/// The processes are matched using their executable paths. Processes for which the path cannot be
/// obtained are ignored.
pub fn is_already_running(install_dir: &Path, ignored_pids: &[Pid]) -> Result<Option<String>> {
let install_dir = install_dir.canonicalize()?;
let mut offending_processes = vec![];
// First get process list.
let mut sys = sysinfo::System::new();
sys.refresh_processes();
for (pid, process) in sys.processes() {
if ignored_pids.contains(pid) {
trace!("Process {} ({}) is ignored.", process.name(), pid);
continue;
}
let Some(path) = process.exe() else {
warn!("Process {} ({}) has no path.", process.name(), pid);
continue;
};
let Ok(path) = path.canonicalize() else {
warn!("Failed to canonicalize process path: {}", path.display());
continue;
};
if path.starts_with(&install_dir) {
offending_processes.push(process);
info!("Process {} ({}) is in the installation directory.", process.name(), pid);
} else {
trace!("Process {} ({}) is not in the installation directory.", process.name(), pid);
}
}
if !offending_processes.is_empty() {
let processes_list = offending_processes
.iter()
.map(|p| format!(" * {} (pid {})", p.name(), p.pid()))
.join("\n");
let message = format!("It seems that the application is currently running. Please close it before running the installer.\n\nThe following processes are running from the installation directory:\n{}", processes_list);
Ok(Some(message))
} else {
Ok(None)
}
}
/// Setup logging for the installer. It logs both to stderr and to a file in the temp directory.
///
/// Returns the path to the generated log file.
pub fn setup_logging(app_name: &str) -> Result<PathBuf> {
use tracing_subscriber::prelude::*;
// Generate filename based on the current time.
let timestamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S");
let filename = format!("{app_name}-{timestamp}.log");
let temp_location = std::env::temp_dir();
let log_file = temp_location.join(filename);
let file = ide_ci::fs::create(&log_file)?;
let registry = tracing_subscriber::Registry::default()
.with(GlobalFilteringLayer)
.with(stderr_log_layer())
.with(file_log_layer(file));
tracing::subscriber::set_global_default(registry)
.context("Failed to set global default subscriber.")?;
Ok(log_file)
}

192
build/install/src/win.rs Normal file
View File

@ -0,0 +1,192 @@
//! Windows-specific code for the Enso installer.
// === Non-Standard Linter Configuration ===
#![allow(unsafe_code)]
use crate::prelude::*;
use winreg::enums::*;
use enso_install_config::ENSO_INSTALL_ARCHIVE_PATH;
use enso_install_config::INSTALLER_PAYLOAD_ID;
use std::ffi::c_void;
#[cfg(windows)]
use std::os::windows::ffi::OsStringExt;
use windows::core::PCWSTR;
use windows::Win32::UI::Shell;
// ==============
// === Export ===
// ==============
pub mod app_paths;
pub mod prog_id;
pub mod registry;
pub mod resource;
pub mod shortcut;
pub mod ui;
pub mod uninstall;
/// Open the `HKEY_CURRENT_USER\Software\Classes` key for reading and writing.
///
/// This is where the programmatic identifiers (ProgIDs) of file types and URL protocols are
/// stored.
pub fn classes_key() -> Result<RegKey> {
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags(r"Software\Classes", KEY_READ | KEY_WRITE)
.context(r"Failed to open `HKEY_CURRENT_USER\Software\Classes` key.")
}
/// Get the local user's Desktop directory path.
///
/// Please note that this is not the same as the `%USERPROFILE%\Desktop`!
/// The desktop location can be redirected to a different location, e.g. OneDrive.
pub fn desktop() -> Result<PathBuf> {
dirs::desktop_dir().context("Failed to get the local user's Desktop directory path.")
}
/// Start Menu programs location for the local user.
///
/// By default, this is `%APPDATA%\Microsoft\Windows\Start Menu\Programs`.
pub fn start_menu_programs() -> Result<PathBuf> {
known_folder(Shell::FOLDERID_Programs)
.context("Failed to get the local user's Start Menu programs directory path.")
}
/// The directory intended for the installation of user-specific non-roaming applications.
///
/// E.g. `C:\Users\username\AppData\Local\Programs`.
///
/// # Errors
/// This function will return an error if the directory does not exist. This happens on brand new
/// Windows installations or new user profiles.
pub fn user_program_files() -> Result<PathBuf> {
known_folder(Shell::FOLDERID_UserProgramFiles)
.context("Failed to get the local user's Program Files directory path.")
}
/// The local application data directory.
///
/// By default, this is `%LOCALAPPDATA%`, being same as `%USERPROFILE%\AppData\Local`.
/// For example, `C:\Users\username\AppData\Local`.
pub fn local_app_data() -> Result<PathBuf> {
known_folder(Shell::FOLDERID_LocalAppData)
.context("Failed to get the local user's Local AppData directory path.")
}
/// Query WinAPI for the path of a known folder.
///
/// Please refer to the [official documentation](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid#constants) for the list of available folders.
#[context("Failed to get the path of a known folder by GUID {folder_id:?}.")]
pub fn known_folder(folder_id: windows::core::GUID) -> Result<PathBuf> {
#[cfg(windows)]
unsafe {
let path = Shell::SHGetKnownFolderPath(&folder_id, default(), None)?;
let ret = OsString::from_wide(path.as_wide());
windows::Win32::System::Com::CoTaskMemFree(Some(path.0 as *mut c_void));
Ok(ret.into())
}
#[cfg(not(windows))]
panic!("Not supported on non-Windows platforms.")
}
/// Notify shell that file associations or registered protocol have changed.
///
/// This is needed to make the changes visible without restarting the system. See:
/// * https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#setting-optional-subkeys-and-file-type-extension-attributes
/// * https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shchangenotify
pub fn refresh_file_associations() {
// All direct WinAPI calls are unsafe (Rust-wise), however this particular one should never be
// able to cause any harm. It cannot even fail API-wise (it returns `void`).
unsafe {
Shell::SHChangeNotify(Shell::SHCNE_ASSOCCHANGED, Shell::SHCNF_FLAGS(0), None, None);
}
}
/// Application-defined resource (raw data).
///
/// See https://learn.microsoft.com/en-us/windows/win32/menurc/resource-types
pub const RT_RCDATA: PCWSTR = PCWSTR(10 as _);
/// Path to icon, as used in various places in registry (e.g. `DefaultIcon`).
#[derive(Debug, Clone)]
pub struct Icon {
/// Path to the executable (or DLL) containing the icon.
pub executable_path: PathBuf,
/// Index of the icon in the executable.
pub icon_index: u32,
}
impl Display for Icon {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, r#""{}",{}"#, self.executable_path.display(), self.icon_index)
}
}
impl ToRegValue for Icon {
fn to_reg_value(&self) -> RegValue {
self.to_string().to_reg_value()
}
}
impl Icon {
/// Use the first icon in the given PE file.
pub fn new(executable_path: impl Into<PathBuf>) -> Self {
Self { executable_path: executable_path.into(), icon_index: 0 }
}
/// Store under the `DefaultIcon` subkey of the registry key.
pub fn write_default_icon_subkey(&self, key: &RegKey) -> Result {
let (icon_key, _disposition) = registry::create_subkey(key, "DefaultIcon")?;
registry::set_value(&icon_key, "", self)
}
}
/// A simple command that can be used by the shell to open a file/url with our application.
///
/// It will simply start the application, giving the file/url as the first argument.
#[derive(Debug, Clone)]
pub struct PlainOpenCommand {
/// Path to the executable.
pub executable_path: PathBuf,
}
impl Display for PlainOpenCommand {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
// Note that the quotes around both paths are required for paths with spaces.
write!(f, r#""{}" "%1""#, self.executable_path.display())
}
}
impl ToRegValue for PlainOpenCommand {
fn to_reg_value(&self) -> RegValue {
self.to_string().to_reg_value()
}
}
impl PlainOpenCommand {
/// Create a new command that will open the given executable.
pub fn new(executable_path: impl Into<PathBuf>) -> Self {
Self { executable_path: executable_path.into() }
}
/// Set this as a shell command to open given entity (represented by the key).
///
/// The command will be stored under the `shell\open\command` subkey of the given key.
#[context("Failed to set shell open command.")]
pub fn set_as_shell_open_command(&self, key: &RegKey) -> Result {
let command_key = registry::create_subkey(key, r"shell\open\command")?.0;
registry::set_value(&command_key, "", self)
}
}
/// Get the binary payload of the installer that was compiled into the executable.
pub fn get_installer_payload() -> Result<&'static [u8]> {
resource::get_binary(INSTALLER_PAYLOAD_ID).with_context(|| format!("Failed to get the installer payload. Was {ENSO_INSTALL_ARCHIVE_PATH} defined during the build?"))
}

View File

@ -0,0 +1,65 @@
//! Helpers for managing known application paths on Windows.
//!
//! The `App Paths` registry key is necessary for the Windows shell to find the
//! application executable when it is invoked by name.
//!
//! See https://docs.microsoft.com/en-us/windows/win32/shell/app-registration
use crate::prelude::*;
use winreg::enums::*;
/// The registry key path for the `App Paths` in Windows, relative to `HKEY_CURRENT_USER`.
pub const APP_PATHS: &str = r"Software\Microsoft\Windows\CurrentVersion\App Paths";
/// Information stored in the `App Paths` registry key for an application.
#[derive(Clone, Debug)]
pub struct AppPathInfo {
/// The path to the application executable.
///
/// It is used both for:
/// * executable filename (which will allow shell to find the executable when it is invoked by
/// name, e.g. when using `Win+R`)
/// * full qualified executable path that will be used to launch the application.
pub executable_path: PathBuf,
/// Additional directories that will be prepended to the `PATH` environment variable when
/// launching the application through the `ShellExecuteEx` API.
pub additional_directories: Vec<PathBuf>,
}
impl AppPathInfo {
/// Create a new `AppPathInfo` instance.
pub fn new(executable_path: impl Into<PathBuf>) -> Self {
Self {
executable_path: executable_path.into(),
// By default don't add anything to path, as we don't want to rely on any
// Windows shell-specific behavior.
additional_directories: vec![],
}
}
/// Write the application path information to the registry.
pub fn write_to_registry(&self) -> Result {
let executable_name = self.executable_path.try_file_name()?.as_str();
let app_paths = crate::win::registry::open_subkey_with_flags(
&RegKey::predef(HKEY_CURRENT_USER),
APP_PATHS,
KEY_READ | KEY_WRITE,
)?;
let (exe_key, _) = crate::win::registry::create_subkey(&app_paths, executable_name)?;
crate::win::registry::set_value(&exe_key, "", &self.executable_path.as_str())?;
if !self.additional_directories.is_empty() {
let path = self
.additional_directories
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(";");
crate::win::registry::set_value(&exe_key, "Path", &path)?;
}
Ok(())
}
}

View File

@ -0,0 +1,195 @@
//! Code for handling [programmatic identifiers](https://docs.microsoft.com/en-us/windows/win32/com/programmatic-identifiers).
//!
//! Basically, there are two kinds of programmatic identifiers:
//! * File extension associations;
//! * URL protocol associations.
//!
//! Programmatic identifiers are stored in the Windows registry under the:
//! * `HKEY_CURRENT_USER\Software\Classes` key for the current user;
//! * `HKEY_LOCAL_MACHINE\Software\Classes` key for all users.
//!
//! `HKEY_CLASSES_ROOT` is a merged view of these two keys.
use crate::prelude::*;
use winreg::enums::*;
use crate::win::registry::create_subkey;
/// A set of broad categories for file types hard-coded into Windows.
///
/// See: https://learn.microsoft.com/en-us/windows/win32/api/shtypes/ne-shtypes-perceived
#[derive(Debug, Clone, Copy, strum::AsRefStr)]
// [mwu] I have no idea if these are case-sensitive but why would we want to risk it?
#[strum(serialize_all = "snake_case")]
pub enum PerceivedType {
Folder,
Text,
Image,
Audio,
Video,
Compressed,
Document,
System,
Application,
Gamemedia,
Contacts,
}
impl PerceivedType {
/// Deduce `PerceivedType` from MIME type.
pub fn from_mime_type(mime_type: &str) -> Result<Self> {
Ok(match mime_type {
"text/plain" => Self::Text,
"application/gzip" | "application/zip" => Self::Compressed,
_ => bail!("MIME type without a corresponding perceived type: '{mime_type}'."),
})
}
}
/// The file extension description.
///
/// Apart from some basic information, it directs to the ProgID of the file type.
#[derive(Clone, Debug)]
pub struct FileExtension {
/// The file extension including the leading dot, e.g. `.enso`.
pub extension: String,
/// The `ProgID` of the file type.
pub prog_id: String,
/// The MIME type of the file.
pub mime_type: String,
/// A broad category for the file type, e.g. `text`.
pub perceived_type: PerceivedType,
}
impl FileExtension {
/// Write information about the file extension to the Windows registry.
///
/// Note that this only writes a reference to the ProgID of the file type. The file type
/// itself must be registered separately using [`FileType::register`].
pub fn register(&self) -> Result {
let classes = crate::win::classes_key()?;
// https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#setting-optional-subkeys-and-file-type-extension-attributes
let (file_ext_key, _disposition) = classes.create_subkey(&self.extension)?;
file_ext_key.set_value("", &self.prog_id)?;
file_ext_key.set_value("Content Type", &self.mime_type)?;
file_ext_key.set_value("PerceivedType", &"text")?;
Ok(())
}
}
/// The Programmatic Identifier (ProgID) of a file type.
///
/// These are associated with file extensions in the Windows registry and describe how to
/// open files of that type. More than one file extension can be associated with a single
/// ProgID.
#[derive(Clone, Debug)]
pub struct FileType {
/// The absolute path to the application executable.
pub application_path: PathBuf,
/// The `ProgID` of the file type - a unique identifier for the file type.
pub prog_id: String,
/// The friendly name of the file type.
pub friendly_name: String,
/// The text to display in the info popup when hovering over a file of this type.
pub info_tip: String,
}
impl FileType {
/// Write information about the file type to the Windows registry.
pub fn register(&self) -> Result {
let classes = crate::win::classes_key()?;
let path_str = self.application_path.as_str();
// https://learn.microsoft.com/en-us/windows/win32/shell/fa-progids
// Describe the Programmatic Identifier (ProgID) of the file type.
let (prog_id_key, _disposition) = classes.create_subkey(&self.prog_id)?;
prog_id_key.set_value("", &self.friendly_name)?;
prog_id_key.set_value("FriendlyTypeName", &self.friendly_name)?;
prog_id_key.set_value("InfoTip", &self.info_tip)?;
// 0 is the index of the icon in the executable (i.e. the first icon).
// See: https://docs.microsoft.com/en-us/windows/win32/shell/fa-file-types#setting-optional-subkeys-and-file-type-extension-attributes
prog_id_key.create_subkey("DefaultIcon")?.0.set_value("", &format!(r#""{path_str}",0"#))?;
prog_id_key
.create_subkey(r"shell\open\command")?
.0
// Note that the quotes around both paths are required for paths with spaces.
.set_value("", &format!(r#""{path_str}" "%1""#))?;
Ok(())
}
}
/// Delete given programmatic identifier from the Windows registry.
#[context("Failed to delete ProgID `{}`.", prog_id)]
pub fn delete(prog_id: &str) -> Result {
// Must be non-empty, or we will delete the entire `Classes` key.
ensure!(!prog_id.is_empty(), "Programmatic identifier must not be empty.");
let classes = crate::win::classes_key()?;
classes.delete_subkey_all(prog_id)?;
Ok(())
}
/// Information about a URL protocol.
#[derive(Debug)]
pub struct ProtocolInfo {
/// The protocol sheme name, e.g. `enso`.
pub protocol: String,
/// The icon for the protocol.
pub icon: crate::win::Icon,
/// The command to run when opening the protocol's URL.
pub command: crate::win::PlainOpenCommand,
/// Display name of the protocol.
pub display_name: Option<String>,
}
impl ProtocolInfo {
pub fn new(protocol: impl Into<String>, executable_path: impl Into<PathBuf>) -> Self {
let protocol = protocol.into();
let display_name = Some(format!("URL:{protocol} protocol"));
let executable_path = executable_path.into();
Self {
protocol,
icon: crate::win::Icon::new(&executable_path),
command: crate::win::PlainOpenCommand::new(executable_path),
display_name,
}
}
/// Write information about the protocol to the Windows registry.
pub fn register(&self) -> Result {
let classes = crate::win::classes_key()?;
let (protocol_key, _) = create_subkey(&classes, &self.protocol)?;
protocol_key.set_value("URL Protocol", &"")?;
if let Some(display_name) = &self.display_name {
protocol_key.set_value("", display_name)?;
}
self.icon.write_default_icon_subkey(&protocol_key)?;
self.command.set_as_shell_open_command(&protocol_key)?;
Ok(())
}
}
/// Set application as the default handler for the given URL protocol, e.g. `enso://`.
///
/// This is necessary for the deep linking to work.
pub fn register_url_protocol(executable_path: &Path, protocol: &str) -> Result {
// See https://learn.microsoft.com/en-us/windows/win32/shell/app-registration
// Register the URL protocol.
let (url_key, _) = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags(r"Software\Classes", KEY_READ | KEY_WRITE)
.context("Failed to open `HKEY_CURRENT_USER\\Software\\Classes` key.")?
.create_subkey(protocol)
.with_context(|| format!(r#"Failed to create subkey for protocol `{protocol}`"#))?;
url_key.set_value("", &format!("URL:{protocol}"))?;
url_key.set_value("URL Protocol", &"")?;
let (command_key, _) = url_key
.create_subkey(r"shell\open\command")
.with_context(|| format!(r#"Failed to create subkey for protocol: {protocol}"#))?;
command_key.set_value("", &format!(r#""{}" "%1""#, executable_path.display()))?;
Ok(())
}

View File

@ -0,0 +1,31 @@
//! Wrapper around the `winreg` crate functions that provides better error messages.
use crate::prelude::*;
use winreg::enums::RegDisposition;
/// Wrapper around [`RegKey::delete_subkey_all`] that provides better error messages.
pub fn delete_subkey_all(key: &RegKey, subkey: &str) -> Result {
key.delete_subkey_all(subkey)
.with_context(|| format!("Failed to delete subkey `{subkey}` in `{key:?}`."))
}
/// Wrapper around [`RegKey::create_subkey`] that provides better error messages.
pub fn create_subkey(key: &RegKey, subkey: &str) -> Result<(RegKey, RegDisposition)> {
key.create_subkey(subkey)
.with_context(|| format!("Failed to create subkey `{subkey}` in `{key:?}`."))
}
/// Wrapper around [`RegKey::open_subkey_with_flags`] that provides better error messages.
pub fn open_subkey_with_flags(key: &RegKey, subkey: &str, flags: u32) -> Result<RegKey> {
key.open_subkey_with_flags(subkey, flags)
.with_context(|| format!("Failed to open subkey `{subkey}` in `{key:?}`."))
}
/// Wrapper around [`RegKey::set_value`] that provides better error messages.
pub fn set_value(key: &RegKey, name: &str, value: &impl ToRegValue) -> Result {
key.set_value(name, value)
.with_context(|| format!("Failed to set value `{name}` in `{key:?}`."))
}

View File

@ -0,0 +1,28 @@
//! Utilities for working with Resources embedded in Windows executables.
use crate::prelude::*;
use windows::core::HSTRING;
use windows::Win32::Foundation;
use windows::Win32::System::LibraryLoader;
/// Get binary resource embedded in the current executable.
///
/// The resource must be compiled into the current executable as `RCDATA`.
#[context("Failed to get binary resource named `{name}`.")]
pub fn get_binary(name: &str) -> Result<&'static [u8]> {
unsafe {
// Clear error, so any `GetLastError` call after this one will return actual error from the
// subsequent calls.
Foundation::SetLastError(Foundation::WIN32_ERROR::default());
let resource =
LibraryLoader::FindResourceW(None, &HSTRING::from(name), crate::win::RT_RCDATA);
Foundation::GetLastError().with_context(|| format!("Failed to find resource: {name:?}"))?;
let global = LibraryLoader::LoadResource(None, resource).unwrap();
let data = LibraryLoader::LockResource(global);
let size = LibraryLoader::SizeofResource(None, resource);
Ok(std::slice::from_raw_parts(data as *const u8, size as _))
}
}

View File

@ -0,0 +1,65 @@
//! Utilities for managing start menu and desktop shortcuts.
use crate::prelude::*;
use ide_ci::env::consts::SHORTCUT_SUFFIX;
/// Location of the shortcut.
#[derive(Copy, Clone, Debug, Display)]
pub enum Location {
/// Local user's Start Menu shortcut (in the Programs folder).
Menu,
/// Local user's Desktop shortcut.
Desktop,
}
impl Location {
/// Get the directory where the shortcut should be created.
#[context("Failed to get {self} shortcut location.")]
pub fn directory(&self) -> Result<PathBuf> {
match self {
Self::Menu => Ok(crate::win::start_menu_programs()?),
Self::Desktop => Ok(crate::win::desktop()?),
}
}
/// Get the shortcut file path.
#[context("Failed to get {self} shortcut '{name}' path.")]
pub fn shortcut_path(&self, name: &str) -> Result<PathBuf> {
Ok(self.directory()?.join(name).with_extension(SHORTCUT_SUFFIX))
}
/// Create shortcut that links to target.
#[context("Failed to create {self} shortcut `{}`.", name)]
pub fn create_shortcut(&self, name: &str, target: &Path) -> Result {
let shortcut_path = self.shortcut_path(name)?;
create_shortcut_customized(&shortcut_path, target, |link| {
link.set_name(Some(name.into()));
})
}
/// Remove the shortcut.
#[context("Failed to remove {self} shortcut `{}`.", name)]
pub fn remove_shortcut(&self, name: &str) -> Result {
let shortcut_path = self.shortcut_path(name)?;
ide_ci::fs::remove_file_if_exists(shortcut_path)
}
}
/// Create a Windows shortcut (`.lnk` file).
pub fn create_shortcut_customized(
shortcut_path: &Path,
target: &Path,
f: impl FnOnce(&mut mslnk::ShellLink),
) -> Result {
// Paths with verbatim prefix (i.e. `\\?\`) are not supported by the Windows Shell API.
let target = target.without_verbatim_prefix();
info!("Creating shortcut {} -> {}", shortcut_path.display(), target.display());
ide_ci::fs::create_parent_dir_if_missing(shortcut_path)?;
let mut link = mslnk::ShellLink::new(target)?;
f(&mut link);
link.create_lnk(shortcut_path)?;
Ok(())
}

View File

@ -0,0 +1,23 @@
//! UI-related utilities for the Windows installer/uninstaller.
use crate::prelude::*;
/// [Sets up logging](crate::setup_logging). Handles any errors by showing a fatal message box.
///
/// # Panics
/// This function panics if it fails to setup logging. The panic will follow immediately after the
/// error message box is closed.
pub fn setup_logging_or_fatal(app_name: &str, dialog_title: &str) -> PathBuf {
match crate::setup_logging(app_name) {
Ok(logfile) => {
info!("Logging to: {}", logfile.display());
logfile
}
Err(err) => {
let message = format!("Failed to create a log file: {err:?}");
native_windows_gui::fatal_message(dialog_title, &message);
}
}
}

View File

@ -0,0 +1,131 @@
//! Uninstaller information for Windows.
//!
//! These functions allow you to write uninstall information to the registry, and remove it when the
//! application is uninstalled.
use crate::prelude::*;
use winreg::enums::*;
/// The path to the Uninstall subkey of the registry.
const UNINSTALL_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
/// Struct representing the uninstall information stored in the `Uninstall` key.
#[derive(Debug)]
pub struct UninstallInfo {
/// The name of the application as it appears in the Add/Remove Programs dialog.
pub display_name: String,
/// The command that will be executed when the user chooses to uninstall the application.
pub uninstall_string: String,
/// The path to the icon that will be displayed next to the application in the Add/Remove
/// Programs dialog.
pub display_icon: Option<String>,
/// The version of the application.
pub display_version: Option<String>,
/// The publisher of the application.
pub publisher: Option<String>,
/// A URL for the About button in the Add/Remove Programs dialog.
pub url_info_about: Option<String>,
/// A URL for the Update button in the Add/Remove Programs dialog.
pub url_update_info: Option<String>,
/// A URL for the Help button in the Add/Remove Programs dialog.
pub help_link: Option<String>,
/// The installation location of the application.
pub install_location: Option<String>,
/// The date the application was installed.
pub install_date: Option<String>,
/// The size of the application in kibibytes.
pub estimated_size_kib: Option<u32>,
}
impl UninstallInfo {
pub fn new(display_name: impl Into<String>, uninstall_string: impl Into<String>) -> Self {
Self {
display_name: display_name.into(),
uninstall_string: uninstall_string.into(),
display_icon: None,
display_version: None,
publisher: None,
url_info_about: None,
url_update_info: None,
help_link: None,
install_location: None,
install_date: None,
estimated_size_kib: None,
}
}
#[context("Failed to write '{app_key}' uninstall information to the registry.")]
pub fn write_to_registry(&self, app_key: &str) -> Result {
trace!("Writing uninstall information to the registry: {self:#?}");
let uninstall_key = open_uninstall_key()?;
// Create a new key for our application
let (app_uninstall_key, _) = uninstall_key.create_subkey(app_key)?;
// Set the necessary values
app_uninstall_key.set_value("DisplayName", &self.display_name)?;
app_uninstall_key.set_value("UninstallString", &self.uninstall_string)?;
if let Some(display_icon) = &self.display_icon {
app_uninstall_key.set_value("DisplayIcon", display_icon)?;
}
if let Some(display_version) = &self.display_version {
app_uninstall_key.set_value("DisplayVersion", display_version)?;
}
if let Some(publisher) = &self.publisher {
app_uninstall_key.set_value("Publisher", publisher)?;
}
if let Some(url_info_about) = &self.url_info_about {
app_uninstall_key.set_value("URLInfoAbout", url_info_about)?;
}
if let Some(url_update_info) = &self.url_update_info {
app_uninstall_key.set_value("URLUpdateInfo", url_update_info)?;
}
if let Some(help_link) = &self.help_link {
app_uninstall_key.set_value("HelpLink", help_link)?;
}
if let Some(install_location) = &self.install_location {
app_uninstall_key.set_value("InstallLocation", install_location)?;
}
if let Some(install_date) = &self.install_date {
app_uninstall_key.set_value("InstallDate", install_date)?;
}
if let Some(estimated_size) = self.estimated_size_kib {
app_uninstall_key.set_value("EstimatedSize", &estimated_size)?;
}
Ok(())
}
}
/// Open the `Uninstall` key in the registry.
pub fn open_uninstall_key() -> Result<RegKey> {
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags(UNINSTALL_KEY_PATH, KEY_READ | KEY_WRITE)
.with_context(|| {
format!("Failed to open the uninstall key in the registry: {UNINSTALL_KEY_PATH}")
})
}
/// Removes the uninstall information from the registry.
#[context("Failed to remove '{app_key}' uninstall information from the registry.")]
pub fn remove_from_registry(app_key: &str) -> Result {
let uninstall_key = open_uninstall_key()?;
uninstall_key.delete_subkey_all(app_key).with_context(|| {
format!(
"Failed to delete the '{app_key}' subkey from the '{UNINSTALL_KEY_PATH}' in the registry."
)
})
}

View File

@ -0,0 +1,23 @@
[package]
name = "enso-uninstaller"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { workspace = true }
enso-build-base = { path = "../../base" }
enso-build = { path = "../../build" }
enso-install = { path = ".." }
enso-install-config = { path = "../config" }
ide-ci = { path = "../../ci_utils" }
self-replace = "1.3.7"
sysinfo = { workspace = true }
tokio = { workspace = true }
[target.'cfg(windows)'.dependencies]
native-windows-gui = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,35 @@
#![windows_subsystem = "windows"] // Do not display a console window when running the uninstaller.
use enso_install::prelude::*;
// ==============
// === Export ===
// ==============
#[cfg(windows)]
pub mod win;
#[cfg(windows)]
#[tokio::main]
pub async fn main() -> Result {
win::main().await
}
#[cfg(not(windows))]
fn main() -> Result {
bail!("This uninstaller is only supported on Windows.")
}
#[cfg(test)]
mod tests {
#[test]
fn uninstaller_name_matches() {
// Make sure the uninstaller expected filename matches the package name.
assert_eq!(enso_install_config::UNINSTALLER_NAME, env!("CARGO_PKG_NAME"));
}
}

View File

@ -0,0 +1,171 @@
use enso_install::prelude::*;
use enso_install::is_already_running;
use enso_install::locked_installation_lock;
use enso_install::sanitized_electron_builder_config;
/// The parent directory of this (uninstaller) executable.
///
/// This is a good candidate for the install directory of Enso.
fn parent_directory() -> Result<PathBuf> {
let exe_path = ide_ci::env::current_exe()?;
exe_path.try_parent().map(Into::into)
}
/// Delete the uninstaller executable.
///
/// This uses the [`self_replace`] crate to delete the executable on Windows. Running executable
/// cannot be deleted on Windows using the ordinary means.
///
/// This must be invoked before deleting the install directory, if the uninstaller is located in the
/// install directory. Otherwise, the executable makes the directory non-deletable.
fn self_delete(parent_path: &Path) -> Result {
self_replace::self_delete_outside_path(parent_path).with_context(|| {
format!(
"Failed to delete the Enso executable. \
Please delete the file manually: {}",
parent_path.display()
)
})
}
/// Handle an error, logging it and adding it to the list of errors.
fn handle_error<T>(errors: &mut Vec<anyhow::Error>, result: Result<T>) -> Option<T> {
match result {
Err(error) => {
error!("Encountered an error: {error}.");
errors.push(error);
None
}
Ok(value) => Some(value),
}
}
/// Show a dialog with an error message and panic.
fn show_dialog_and_panic(error: &anyhow::Error) -> ! {
error!("Encountered an error: {error:?}.");
native_windows_gui::fatal_message(
"Uninstallation failed",
&format!("Encountered an error: {error}", error = error),
);
}
pub async fn main() -> Result {
let dialog_title = format!("{} installer", sanitized_electron_builder_config().product_name);
let logfile =
enso_install::win::ui::setup_logging_or_fatal(env!("CARGO_PKG_NAME"), &dialog_title);
// Show a yes-no modal dialog.
let message =
format!("Do you want to uninstall {}?", sanitized_electron_builder_config().product_name);
let params = native_windows_gui::MessageParams {
title: &dialog_title,
content: &message,
buttons: native_windows_gui::MessageButtons::YesNo,
icons: native_windows_gui::MessageIcons::Question,
};
match native_windows_gui::message(&params) {
native_windows_gui::MessageChoice::Yes => (),
native_windows_gui::MessageChoice::No => {
info!("User chose not to uninstall.");
return Ok(());
}
_ => bail!("Unexpected message box result."),
}
let mut errors = vec![];
let _guard = match locked_installation_lock() {
Ok(guard) => guard,
Err(error) => show_dialog_and_panic(&error),
};
// Unwrap is safe, because the uninstaller path (being an executable) will never be a root.
let install_dir = parent_directory().unwrap();
// Check if there is already running instance of Enso or any of its subprograms.
let already_running = sysinfo::get_current_pid()
.map_err(|text| anyhow!("Failed to get current process ID: {text}"))
.and_then(|my_pid| {
is_already_running(&install_dir, &[my_pid])
.context("Failed to check if already running.")
});
match already_running {
Ok(Some(message)) => show_dialog_and_panic(&anyhow!("{message}")),
Ok(None) => (),
Err(error) => warn!("Failed to check if there is already running instance: {error:?}"),
};
// Make sure that Enso.exe is in the same directory as this uninstaller. This is to prevent
// situation where just the uninstaller binary is placed by accident elsewhere and ends up
// deleting the whole directory.
let executable_filename = enso_install::executable_filename();
let expected_executable = install_dir.join(&executable_filename);
let shortcut_name = enso_install::shortcut_name();
ensure!(
expected_executable.exists(),
"{} not found in the presumed install directory {}",
executable_filename.display(),
install_dir.display()
);
info!("Remove Add/Remove Programs entry.");
handle_error(
&mut errors,
enso_install::win::uninstall::remove_from_registry(enso_install::uninstall_key()),
);
info!("Removing self (uninstaller) executable.");
handle_error(&mut errors, self_delete(&install_dir));
info!("Removing install directory.");
handle_error(&mut errors, ide_ci::fs::remove_dir_if_exists(&install_dir));
// Remove prog id but leave file extensions - see https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation
for file_association in
&sanitized_electron_builder_config().extra_metadata.installer.file_associations
{
let prog_id = &file_association.prog_id;
info!("Removing ProgID `{prog_id}`.");
handle_error(&mut errors, enso_install::win::prog_id::delete(prog_id));
}
info!("Removing Start Menu entry.");
handle_error(
&mut errors,
enso_install::win::shortcut::Location::Menu.remove_shortcut(shortcut_name),
);
info!("Removing Desktop shortcut.");
handle_error(
&mut errors,
enso_install::win::shortcut::Location::Desktop.remove_shortcut(shortcut_name),
);
if !errors.is_empty() {
error!("Encountered {} errors.", errors.len());
for error in &errors {
error!(" * {error:?}");
}
let errors_summary = errors.iter().map(|e| format!("{e}")).join("\n *");
let plain_message = format!(
"Encountered errors during uninstallation. Some files or \
registry entries may have been left behind. Please see the log file for details. The file \
explorer will be opened at the log file location.\n\nErrors:\n{errors_summary}"
);
native_windows_gui::error_message("Uninstallation failed", &plain_message);
let _ = ide_ci::programs::explorer::show_selected(logfile);
bail!("Uninstallation failed.");
} else {
native_windows_gui::simple_message(
"Uninstallation successful",
"Enso has been successfully uninstalled.",
);
Ok(())
}
}

View File

@ -16,6 +16,7 @@ use enso_parser::syntax::tree::DocComment;
use enso_parser::syntax::tree::TextElement;
// ====================================
// === Debug Representation Printer ===
// ====================================

1
package-lock.json generated
View File

@ -187,6 +187,7 @@
},
"devDependencies": {
"@electron/notarize": "2.1.0",
"@types/node": "^20.10.5",
"electron": "25.7.0",
"electron-builder": "^22.14.13",
"enso-common": "^1.0.0",