diff --git a/res/msi/Package/Components/Regs.wxs b/res/msi/Package/Components/Regs.wxs
index 33d587b1e..25988f4a8 100644
--- a/res/msi/Package/Components/Regs.wxs
+++ b/res/msi/Package/Components/Regs.wxs
@@ -5,6 +5,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -40,17 +57,29 @@
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/msi/Package/Fragments/AddRemoveProperties.wxs b/res/msi/Package/Fragments/AddRemoveProperties.wxs
index ac1d85a86..9f1460234 100644
--- a/res/msi/Package/Fragments/AddRemoveProperties.wxs
+++ b/res/msi/Package/Fragments/AddRemoveProperties.wxs
@@ -27,10 +27,12 @@
+
+
-
+
diff --git a/res/msi/Package/Package.wxs b/res/msi/Package/Package.wxs
index e11756a65..f1109ef67 100644
--- a/res/msi/Package/Package.wxs
+++ b/res/msi/Package/Package.wxs
@@ -14,6 +14,7 @@
+
@@ -22,10 +23,11 @@
-
+
+
@@ -45,7 +47,9 @@
-
+
+
+
diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py
index 4fde48bb0..cd09e499f 100644
--- a/res/msi/preprocess.py
+++ b/res/msi/preprocess.py
@@ -12,6 +12,7 @@ import platform
from pathlib import Path
from itertools import chain
import shutil
+from xml.sax.saxutils import quoteattr
g_indent_unit = "\t"
g_version = ""
@@ -54,7 +55,7 @@ def make_parser():
parser.add_argument(
"--arp",
action="store_true",
- help="Is ARPSYSTEMCOMPONENT",
+ help="Deprecated; native MSI ARP registration is always used.",
default=False,
)
parser.add_argument(
@@ -258,25 +259,19 @@ def gen_custom_dialog_bitmaps():
)
-def gen_custom_ARPSYSTEMCOMPONENT_False(args):
+def gen_native_arp_properties():
def func(lines, index_start):
indent = g_indent_unit * 2
lines_new = []
- lines_new.append(
- f"{indent}\n"
- )
- lines_new.append(
- f'{indent}\n\n'
- )
-
lines_new.append(
f"{indent}\n"
)
for _, v in g_arpsystemcomponent.items():
if "msi" in v and "v" in v:
lines_new.append(
- f'{indent}\n'
+ f'{indent}\n'
)
for i, line in enumerate(lines_new):
@@ -291,94 +286,16 @@ def gen_custom_ARPSYSTEMCOMPONENT_False(args):
)
-def get_folder_size(folder_path):
- total_size = 0
-
- folder = Path(folder_path)
- for file in folder.glob("**/*"):
- if file.is_file():
- total_size += file.stat().st_size
-
- return total_size
-
-
-def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir):
+def gen_install_state_values():
def func(lines, index_start):
indent = g_indent_unit * 5
-
lines_new = []
- lines_new.append(
- f"{indent}\n"
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- installDate = datetime.datetime.now().strftime("%Y%m%d")
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
-
- # EstimatedSize in uninstall registry must be in KB.
- estimated_size_bytes = get_folder_size(dist_dir)
- estimated_size = max(1, (estimated_size_bytes + 1023) // 1024)
- lines_new.append(
- f'{indent}\n'
- )
-
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
-
- vs = g_version.split(".")
- major, minor, build = vs[0], vs[1], vs[2]
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
- lines_new.append(
- f'{indent}\n'
- )
-
- lines_new.append(
- f'{indent}\n'
- )
- for k, v in g_arpsystemcomponent.items():
- if "v" in v:
- t = v["t"] if "t" in v is None else "string"
+ for name, value in g_arpsystemcomponent.items():
+ if "msi" not in value and "v" in value:
+ value_type = value.get("t", "string")
lines_new.append(
- f'{indent}\n'
+ f'{indent}\n'
)
for i, line in enumerate(lines_new):
@@ -387,24 +304,35 @@ def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir):
return gen_content_between_tags(
"Package/Components/Regs.wxs",
- "",
- "",
+ "",
+ "",
func,
)
-def gen_custom_ARPSYSTEMCOMPONENT(args, dist_dir):
+def gen_custom_ARPSYSTEMCOMPONENT(args, _dist_dir):
try:
- custom_arp = json.loads(args.custom_arp)
- g_arpsystemcomponent.update(custom_arp)
- except json.JSONDecodeError as e:
+ custom_arp = dict(json.loads(args.custom_arp))
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
print(f"Failed to decode custom arp: {e}")
return False
- if args.arp:
- return gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir)
- else:
- return gen_custom_ARPSYSTEMCOMPONENT_False(args)
+ if any(not isinstance(value, dict) for value in custom_arp.values()):
+ print("Custom arp entries must be objects.")
+ return False
+
+ if any(
+ isinstance(value, dict) and value.get("msi") == "ARPSYSTEMCOMPONENT"
+ for value in custom_arp.values()
+ ):
+ print("ARPSYSTEMCOMPONENT is not allowed; native MSI ARP registration must remain visible.")
+ return False
+
+ g_arpsystemcomponent.update(custom_arp)
+
+ if not gen_native_arp_properties():
+ return False
+ return gen_install_state_values()
def gen_conn_type(args):
def func(lines, index_start):
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index e32313987..998bd6bad 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -100,6 +100,7 @@ use winreg::{enums::*, RegKey};
mod acl;
mod installer_handoff;
mod installer_shell;
+mod msi_registry;
pub(crate) use acl::current_process_user_sid_string;
pub use acl::{
set_path_permission, set_path_permission_for_portable_service_shmem_dir,
@@ -119,6 +120,13 @@ pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW";
const REG_NAME_INSTALL_DESKTOPSHORTCUTS: &str = "DESKTOPSHORTCUTS";
const REG_NAME_INSTALL_STARTMENUSHORTCUTS: &str = "STARTMENUSHORTCUTS";
pub const REG_NAME_INSTALL_PRINTER: &str = "PRINTER";
+const REG_NAME_MSI_PRODUCT_CODE: &str = "MsiProductCode";
+const REG_NAME_UNINSTALL_STRING: &str = "UninstallString";
+const REG_NAME_WINDOWS_INSTALLER: &str = "WindowsInstaller";
+const MSI_WINDOWS_INSTALLER_VALUE: u32 = 1;
+const MSI_EXIT_SUCCESS_REBOOT_INITIATED: u32 = 1641;
+const MSI_EXIT_SUCCESS_REBOOT_REQUIRED: u32 = 3010;
+const HKLM_PREFIX: &str = "HKEY_LOCAL_MACHINE\\";
fn validate_install_app_name(app_name: &str) -> ResultType<()> {
if app_name.is_empty()
@@ -1305,6 +1313,11 @@ fn get_subkey(name: &str, wow: bool) -> String {
}
fn get_valid_subkey() -> String {
+ let app_name = crate::get_app_name();
+ let subkey = format!("{HKLM_PREFIX}Software\\{app_name}\\InstallState\\{app_name}");
+ if !get_reg_of(&subkey, "InstallLocation").is_empty() {
+ return subkey;
+ }
let subkey = get_subkey(IS1, false);
if !get_reg_of(&subkey, "InstallLocation").is_empty() {
return subkey;
@@ -1313,7 +1326,6 @@ fn get_valid_subkey() -> String {
if !get_reg_of(&subkey, "InstallLocation").is_empty() {
return subkey;
}
- let app_name = crate::get_app_name();
let subkey = get_subkey(&app_name, true);
if !get_reg_of(&subkey, "InstallLocation").is_empty() {
return subkey;
@@ -1572,7 +1584,12 @@ fn get_after_install(
}
pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> ResultType<()> {
- let uninstall_str = get_uninstall(false, false);
+ // MSI and EXE installations use different registry layouts, so MSI-to-EXE upgrades are not supported.
+ let (installed_subkey, _, _, _) = get_install_info();
+ if get_windows_installer_state(&installed_subkey)? == Some(true) {
+ bail!("Cannot install the EXE package over an existing MSI installation");
+ }
+ let uninstall_str = get_uninstall(false, false)?;
let mut path = path.trim_end_matches('\\').to_owned();
let (subkey, _path, start_menu, exe) = get_default_install_info();
let mut exe = exe;
@@ -1804,10 +1821,14 @@ fn get_before_uninstall(kill_self: bool) -> String {
/// The `uninstall_printer` parameter determines whether the command to uninstall the remote printer
/// is included in the generated uninstall script. If `uninstall_printer` is `false`, the printer
/// related command is omitted from the script.
-fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String {
- let reg_uninstall_string = get_reg("UninstallString");
- if reg_uninstall_string.to_lowercase().contains("msiexec.exe") {
- return reg_uninstall_string;
+fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> ResultType {
+ let (subkey, path, start_menu, _) = get_install_info();
+ let installer_state = get_windows_installer_state(&subkey)?;
+ if let Some(product_code) = get_msi_product_code(&subkey, installer_state)? {
+ return Ok(build_msi_uninstall_command(&product_code));
+ }
+ if installer_state == Some(true) {
+ bail!("MSI product code was not found in {subkey}");
}
let mut uninstall_cert_cmd = "".to_string();
@@ -1820,8 +1841,7 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String {
}
}
}
- let (subkey, path, start_menu, _) = get_install_info();
- format!(
+ Ok(format!(
"
{before_uninstall}
{uninstall_printer_cmd}
@@ -1836,11 +1856,11 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String {
before_uninstall=get_before_uninstall(kill_self),
uninstall_amyuni_idd=get_uninstall_amyuni_idd(),
app_name = crate::get_app_name(),
- )
+ ))
}
pub fn uninstall_me(kill_self: bool) -> ResultType<()> {
- run_cmds(get_uninstall(kill_self, true), true, "uninstall")
+ run_cmds(get_uninstall(kill_self, true)?, true, "uninstall")
}
fn write_vbs(cmds: String, tip: &str) -> ResultType {
@@ -3389,6 +3409,8 @@ pub fn update_me(debug: bool) -> ResultType<()> {
if !is_installed {
bail!("{} is not installed.", &app_name);
}
+ let is_msi = is_msi_installed().ok();
+ let reg_msi_key = get_reg_msi_key(&subkey, is_msi)?;
let app_exe_name = &format!("{}.exe", &app_name);
// NOTE: The pids below are matched by command line, which can silently come
@@ -3439,8 +3461,6 @@ pub fn update_me(debug: bool) -> ResultType<()> {
// Use the icon in the previous installation directory if possible.
let display_icon = get_custom_icon("", &exe).unwrap_or(exe.to_string());
- let is_msi = is_msi_installed().ok();
-
fn get_reg_cmd(
subkey: &str,
is_msi: Option,
@@ -3486,18 +3506,10 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
&version_build,
size,
);
- let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
- get_reg_cmd(
- ®_msi_key,
- is_msi,
- &display_icon,
- &version,
- &build_date,
- &version_major,
- &version_minor,
- &version_build,
- size,
- )
+ let reg_cmd_msi = if let Some(reg_msi_key) = ®_msi_key {
+ // This is best-effort: failure may leave a stale version in the Windows app list,
+ // but should not interrupt the update.
+ format!("reg add {reg_msi_key} /f /v DisplayVersion /t REG_SZ /d \"{version}\"")
} else {
"".to_owned()
};
@@ -3621,34 +3633,147 @@ taskkill /F /IM {app_name}.exe{filter}
Ok(())
}
-fn get_reg_msi_key(subkey: &str, is_msi: Option) -> Option {
+fn normalize_msi_product_code(value: &str) -> Option {
+ let value = value.trim().trim_matches('"');
+ let value = value.strip_prefix('{')?.strip_suffix('}')?;
+ let product_code = uuid::Uuid::parse_str(value).ok()?;
+ Some(format!("{{{}}}", product_code.hyphenated()).to_uppercase())
+}
+
+fn build_msi_uninstall_command(product_code: &str) -> String {
+ format!(
+ "set \"RUSTDESK_MSI_EXIT_CODE=\"\n\
+MsiExec.exe /X {product_code} /norestart REBOOT=ReallySuppress\n\
+set \"RUSTDESK_MSI_EXIT_CODE=%ERRORLEVEL%\"\n\
+if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" echo MSI uninstall succeeded with a reboot recommendation; continuing without reboot.\n\
+if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" echo MSI uninstall succeeded with a reboot request; continuing without forcing reboot.\n\
+if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"0\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" exit /b %RUSTDESK_MSI_EXIT_CODE%\n\
+ver > nul"
+ )
+}
+
+fn get_reg_string_of(subkey: &str, name: &str) -> ResultType