diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 409b5201da7..bf1b7610cf6 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -389,6 +389,54 @@ jobs: mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi sha256sum ../../SignOutput/rustdesk-*.msi + - name: Build pre-built MSI template + # Two things this works around: preprocess.py rewrites res/msi in place, so the + # tree is reset around this second variant; and it locates the app as + # .exe inside the dist, so the dist copy is renamed to match. + # + # The placeholder is chosen to keep this template as close to the shipped msi as + # possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX + # derives no short name for it. A longer placeholder would get one, and a patch + # cannot rewrite a truncated placeholder, leaving short names pointing at it. + # + # It still has to be unique, which is why "RustDesk" itself cannot be used: + # it also names payload that must never be renamed, such as librustdesk.dll + # and drivers\RustDeskPrinterDriver. + # + # + # Building the arm64 template on the native arm64 runner makes the ARM + # package available: the build agents are x64 and cannot run + # preprocess.py against an ARM exe. + if: env.UPLOAD_ARTIFACT == 'true' + run: | + git checkout -- res/msi + cp -r ./rustdesk ./rustdesk-msi-template + mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe + Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline + $assets = './rustdesk-msi-template/data/flutter_assets/assets' + New-Item -ItemType Directory -Force -Path $assets | Out-Null + foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') { + Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline + } + pushd ./res/msi + python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM + $msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' } + msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform + msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10 + $msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1 + popd + mkdir ./msi-template + mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi + git checkout -- res/msi + rm -r -fo ./rustdesk-msi-template + + - name: Upload unsigned msi template + if: env.UPLOAD_ARTIFACT == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }} + path: ./msi-template + - name: Sign rustdesk self-extracted file if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' shell: bash @@ -925,15 +973,33 @@ jobs: name: rustdesk-unsigned-windows-x86_64 path: ./windows-x86_64/ + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rustdesk-unsigned-windows-aarch64 + path: ./windows-aarch64/ + - name: Download Artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: rustdesk-unsigned-windows-x86 path: ./windows-x86/ + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rustdesk-unsigned-msi-template-x86_64 + path: ./msi-template/ + + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rustdesk-unsigned-msi-template-aarch64 + path: ./msi-template/ + - name: Combine unsigned app run: | - tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86 + tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template - name: Publish unsigned app uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index bcf08f38627..165d9834957 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -12,7 +12,7 @@ build = "build.rs" brotli = "3.4" dirs = "5.0" md5 = "0.7" -winapi = { version = "0.3", features = ["winbase"] } +winapi = { version = "0.3", features = ["winbase", "libloaderapi"] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = [ diff --git a/libs/portable/generate.py b/libs/portable/generate.py index d5468a5dc7f..26d0b37798c 100755 --- a/libs/portable/generate.py +++ b/libs/portable/generate.py @@ -15,15 +15,29 @@ # output: {path: (compressed_data, file_md5)} -def generate_md5_table(folder: str, level) -> dict: +def normalize(path: str) -> str: + path = path.replace('\\', '/') + while path.startswith('./'): + path = path[2:] + return path.lower() + + +def generate_md5_table(folder: str, level, exclude: str = None) -> dict: res: dict = dict() - curdir = os.curdir + skip = normalize(exclude) if exclude else None + excluded = False + # os.curdir is the literal ".", so restoring it left us inside `folder`. + curdir = os.getcwd() os.chdir(folder) for root, _, files in os.walk('.'): # remove ./ for f in files: md5_generator = md5() full_path = os.path.join(root, f) + if skip and normalize(full_path) == skip: + print(f"Excluding {full_path}...") + excluded = True + continue print(f"Processing {full_path}...") f = open(full_path, "rb") content = f.read() @@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict: md5_code = md5_generator.hexdigest().encode(encoding=encoding) res[full_path] = (content_compressed, md5_code) os.chdir(curdir) + if skip and not excluded: + raise ValueError(f"excluded file was not found in {folder}: {exclude}") return res def write_package_metadata(md5_table: dict, output_folder: str, exe: str): - output_path = os.path.join(output_folder, "data.bin") + write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe) + + +def write_blob(md5_table: dict, output_path: str, exe: str): with open(output_path, "wb") as f: f.write("rustdesk".encode(encoding=encoding)) for path in md5_table.keys(): @@ -92,6 +111,14 @@ def build_portable(output_folder: str, target: str): help="the target used by cargo") parser.add_option("-l", "--level", dest="level", type="int", help="compression level, default is 11, highest", default=11) + parser.add_option("--package", dest="package", + help="write the per-customer blob to this path instead of " + "data.bin, and skip the cargo build. Injected into the " + "template's RDPKG resource so customizing needs no rebuild") + parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true", + default=False, + help="omit the executable from the blob, for a template whose " + "executable ships in the package instead") (options, args) = parser.parse_args() folder = options.folder or './rustdesk' output_folder = os.path.abspath(options.output_folder or './') @@ -100,14 +127,29 @@ def build_portable(output_folder: str, target: str): options.executable = 'rustdesk.exe' if not options.executable.startswith(folder): options.executable = folder + '/' + options.executable + # Note: the simple check `options.executable.startswith(folder)` is incorrect. + # `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk` + # will result the print "Executable path: ..exe". + # So we need to check if the executable is in the folder, and if so, concat again. + if os.path.exists(os.path.join(folder, options.executable)): + options.executable = os.path.join(folder, options.executable) + folder_path = os.path.abspath(folder) exe: str = os.path.abspath(options.executable) - if not exe.startswith(os.path.abspath(folder)): + try: + in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path + except ValueError: + in_source_folder = False + if not in_source_folder: print("The executable must locate in source folder") exit(-1) - exe = '.' + exe[len(os.path.abspath(folder)):] + exe = '.' + exe[len(folder_path):] print("Executable path: " + exe) print("Compression level: " + str(options.level)) - md5_table = generate_md5_table(folder, options.level) - write_package_metadata(md5_table, output_folder, exe) - write_app_metadata(output_folder) - build_portable(output_folder, options.target) + md5_table = generate_md5_table( + folder, options.level, exe if options.exclude_exe else None) + if options.package: + write_blob(md5_table, os.path.abspath(options.package), exe) + else: + write_package_metadata(md5_table, output_folder, exe) + write_app_metadata(output_folder) + build_portable(output_folder, options.target) diff --git a/libs/portable/src/bin_reader.rs b/libs/portable/src/bin_reader.rs index 9effbc5893b..d488e0d1dee 100644 --- a/libs/portable/src/bin_reader.rs +++ b/libs/portable/src/bin_reader.rs @@ -1,15 +1,22 @@ use std::{ + collections::HashSet, fs::{self}, io::{Cursor, Read}, path::Path, }; +// The generic payload, shared by every customer and compiled in once per release. #[cfg(windows)] const BIN_DATA: &[u8] = include_bytes!("../data.bin"); -#[cfg(not(windows))] -const BIN_DATA: &[u8] = &[]; + +// The per-customer payload, injected into the RCDATA resource after the template +// has been built, so that customizing a client needs no recompilation. +#[cfg(windows)] +const PACKAGE_RESOURCE_NAME: &str = "RDPKG"; + // 4bytes const LENGTH: usize = 4; +const IDENTIFIER: &[u8] = b"rustdesk"; const IDENTIFIER_LENGTH: usize = 8; const MD5_LENGTH: usize = 32; const BUF_SIZE: usize = 4096; @@ -24,12 +31,172 @@ pub(crate) struct BinaryData { pub(crate) struct BinaryReader { pub files: Vec, pub exe: String, + // Paths supplied by the per-customer package. Recorded so that a file dropped + // from a later package -- a logo the customer removed, say -- can be deleted + // from an existing extraction, which the timestamp wipe no longer covers now + // that the packer is built once per release rather than once per customer. + pub package_paths: Vec, +} + +impl BinaryReader { + pub fn new() -> Result { + let package = read_package()?; + let package_paths = package.0.iter().map(|f| f.path.clone()).collect(); + let (files, exe) = merge(read_embedded()?, package); + Ok(Self { + files, + exe, + package_paths, + }) + } +} + +// Folds the per-customer package into the generic payload. +fn merge( + embedded: (Vec, String), + package: (Vec, String), +) -> (Vec, String) { + let (mut files, generic_exe) = embedded; + let (package_files, package_exe) = package; + + let exe = if package_exe.is_empty() { + generic_exe.clone() + } else { + package_exe + }; + + // The generic payload ships the executable under its stock name, the package + // decides the final one. Rename on extraction so the process is always + // `.exe`, which the app itself relies on to find its own sessions. + if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) { + let generic_key = normalize_path(&generic_exe); + for file in files.iter_mut() { + if normalize_path(&file.path) == generic_key { + file.path = exe.clone(); + } + } + } + + // Per-customer entries replace the generic ones they shadow. + if !package_files.is_empty() { + let overridden: HashSet = package_files + .iter() + .map(|file| normalize_path(&file.path)) + .collect(); + files.retain(|file| !overridden.contains(&normalize_path(&file.path))); + files.extend(package_files); + } + + (files, exe) +} + +pub(crate) fn normalize_path(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .to_lowercase() +} + +fn read_u32(blob: &[u8], at: usize) -> Option { + let bytes = blob.get(at..at + LENGTH)?; + Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +// Returns the files and the executable to launch, or None if the blob is absent or malformed. +fn parse(blob: &'static [u8]) -> Option<(Vec, String)> { + let mut base = 0usize; + let mut parsed = Vec::new(); + if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER { + return None; + } + base += IDENTIFIER_LENGTH; + loop { + if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER { + base += IDENTIFIER_LENGTH; + break; + } + let path_length = read_u32(blob, base)? as usize; + base += LENGTH; + let path = std::str::from_utf8(blob.get(base..base + path_length)?) + .ok()? + .to_owned(); + base += path_length; + let file_length = read_u32(blob, base)? as usize; + base += LENGTH; + let raw = blob.get(base..base + file_length)?; + base += file_length; + let md5_code = blob.get(base..base + MD5_LENGTH)?; + base += MD5_LENGTH; + parsed.push(BinaryData { + md5_code, + raw, + path, + }); + } + let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned(); + Some((parsed, executable)) +} + +#[cfg(windows)] +fn read_embedded() -> Result<(Vec, String), String> { + parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned()) +} + +#[cfg(not(windows))] +fn read_embedded() -> Result<(Vec, String), String> { + Ok(Default::default()) +} + +fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec, String), String> { + let Some(blob) = blob else { + return Ok(Default::default()); + }; + let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?; + if package.1.trim().is_empty() { + return Err("RDPKG resource has no executable".to_owned()); + } + Ok(package) +} + +#[cfg(windows)] +fn read_package() -> Result<(Vec, String), String> { + parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME)) +} + +#[cfg(not(windows))] +fn read_package() -> Result<(Vec, String), String> { + Ok(Default::default()) } -impl Default for BinaryReader { - fn default() -> Self { - let (files, exe) = BinaryReader::read(); - Self { files, exe } +// Reads an RCDATA resource out of the running image. Resources live in the mapped +// image for the lifetime of the process, so the slice is genuinely 'static and no +// copy is needed. +#[cfg(windows)] +fn read_resource(name: &str) -> Option<&'static [u8]> { + use std::ptr::null_mut; + use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource}; + + // MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA. + const RT_RCDATA: *const u16 = 10 as _; + + let name: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { + let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA); + if info.is_null() { + return None; + } + let size = SizeofResource(null_mut(), info) as usize; + if size == 0 { + return None; + } + let handle = LoadResource(null_mut(), info); + if handle.is_null() { + return None; + } + let data = LockResource(handle) as *const u8; + if data.is_null() { + return None; + } + Some(std::slice::from_raw_parts(data, size)) } } @@ -68,59 +235,6 @@ impl BinaryData { } impl BinaryReader { - fn read() -> (Vec, String) { - let mut base: usize = 0; - let mut parsed = vec![]; - assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!"); - let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]); - if iden != "rustdesk" { - panic!("bin file is not valid!"); - } - base += IDENTIFIER_LENGTH; - loop { - iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]); - if iden == "rustdesk" { - base += IDENTIFIER_LENGTH; - break; - } - // start reading - let mut offset = 0; - let path_length = u32::from_be_bytes([ - BIN_DATA[base + offset], - BIN_DATA[base + offset + 1], - BIN_DATA[base + offset + 2], - BIN_DATA[base + offset + 3], - ]) as usize; - offset += LENGTH; - let path = - String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length]) - .to_string(); - offset += path_length; - // file sz - let file_length = u32::from_be_bytes([ - BIN_DATA[base + offset], - BIN_DATA[base + offset + 1], - BIN_DATA[base + offset + 2], - BIN_DATA[base + offset + 3], - ]) as usize; - offset += LENGTH; - let raw = &BIN_DATA[base + offset..base + offset + file_length]; - offset += file_length; - // md5 - let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH]; - offset += MD5_LENGTH; - parsed.push(BinaryData { - md5_code: md5, - raw: raw, - path: path, - }); - base += offset; - } - // executable - let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string(); - (parsed, executable) - } - #[cfg(linux)] pub fn configure_permission(&self, prefix: &Path) { use std::os::unix::prelude::PermissionsExt; @@ -137,3 +251,155 @@ impl BinaryReader { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Builds a blob in the same layout generate.py writes, so these tests pin the + // cross-language format contract as well as the merge rules. + fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] { + let mut out = Vec::new(); + out.extend_from_slice(IDENTIFIER); + for (path, data) in files { + out.extend_from_slice(&(path.len() as u32).to_be_bytes()); + out.extend_from_slice(path.as_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(&[b'a'; MD5_LENGTH]); + } + out.extend_from_slice(IDENTIFIER); + out.extend_from_slice(exe.as_bytes()); + Box::leak(out.into_boxed_slice()) + } + + fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> { + files + .iter() + .find(|file| normalize_path(&file.path) == normalize_path(path)) + } + + #[test] + fn parses_the_generate_py_layout() { + let (files, exe) = parse(blob( + &[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")], + "./rustdesk.exe", + )) + .unwrap(); + assert_eq!(exe, "./rustdesk.exe"); + assert_eq!(files.len(), 2); + assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg"); + } + + #[test] + fn rejects_malformed_blobs() { + assert!(parse(b"".as_slice()).is_none()); + assert!(parse(b"notrustd".as_slice()).is_none()); + // Truncated mid-record rather than panicking on a slice out of range. + assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none()); + } + + #[test] + fn distinguishes_an_absent_package_from_a_malformed_one() { + assert!(parse_package_blob(None).unwrap().0.is_empty()); + assert!(parse_package_blob(Some(b"damaged")).is_err()); + assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err()); + } + + #[test] + fn without_a_package_the_stock_payload_is_untouched() { + let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap(); + let (files, exe) = merge(embedded, Default::default()); + assert_eq!(exe, "./rustdesk.exe"); + assert!(entry(&files, "./rustdesk.exe").is_some()); + } + + #[test] + fn renames_the_stock_executable_to_the_package_name() { + // x86: the big executable stays in the generic payload and only gets renamed. + let embedded = parse(blob( + &[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")], + "./rustdesk.exe", + )) + .unwrap(); + let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert!(entry(&files, "./acme.exe").is_some()); + assert!(entry(&files, "./rustdesk.exe").is_none()); + // Untouched neighbours survive. + assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll"); + assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg"); + } + + #[test] + fn package_entries_win_over_the_generic_payload() { + // x64: the customized executable and icons ship in the package instead. + let embedded = parse(blob( + &[ + ("./data/flutter_assets/assets/icon.ico", b"stock-icon"), + ("./librustdesk.dll", b"core"), + ], + "./rustdesk.exe", + )) + .unwrap(); + let package = parse(blob( + &[ + ("./acme.exe", b"branded"), + ("./data/flutter_assets/assets/icon.ico", b"acme-icon"), + ], + "./acme.exe", + )) + .unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert_eq!( + entry(&files, "./data/flutter_assets/assets/icon.ico") + .unwrap() + .raw, + b"acme-icon" + ); + assert_eq!( + files + .iter() + .filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico") + .count(), + 1 + ); + assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core"); + } + + #[test] + fn package_paths_are_recorded_for_the_dropped_file_sweep() { + let package = parse(blob( + &[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")], + "./acme.exe", + )) + .unwrap(); + let mut paths: Vec = package.0.iter().map(|f| f.path.clone()).collect(); + paths.sort(); + assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]); + + // Merging must not disturb them: the generic payload contributes none. + let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap(); + let (files, _) = merge(embedded, package); + assert!(entry(&files, "./data/logo.png").is_some()); + } + + #[test] + fn matches_paths_across_separator_styles() { + // generate.py emits backslashes when it runs on Windows. + let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap(); + let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert!(entry(&files, "./acme.exe").is_some()); + assert!(entry(&files, ".\\rustdesk.exe").is_none()); + } +} diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index b7ff44ec55b..284dd5ee2c7 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -5,7 +5,7 @@ use std::{ process::{Command, Stdio}, }; -use bin_reader::BinaryReader; +use bin_reader::{normalize_path, BinaryReader}; pub mod bin_reader; #[cfg(windows)] @@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml"); const APP_METADATA: &[u8] = &[]; const APP_METADATA_CONFIG: &str = "meta.toml"; const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = "; +const META_LINE_PREFIX_FILE: &str = "file = "; const APP_PREFIX: &str = "rustdesk"; const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME"; #[cfg(windows)] const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW"; +// The extraction directory follows whatever executable the payload asks for, so a +// custom client gets its own directory instead of sharing RustDesk's. Falls back to +// APP_PREFIX when no package is injected, which keeps stock builds unchanged. +fn app_dir_name(exe: &str) -> String { + Path::new(&exe.replace('\\', "/")) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.trim().to_lowercase()) + .filter(|stem| !stem.is_empty()) + .unwrap_or_else(|| APP_PREFIX.to_owned()) +} + fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool { let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else { return true; @@ -50,15 +63,95 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool { false } -fn write_meta(dir: &Path, ts: u64) { +fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) { let meta_file = dir.join(APP_METADATA_CONFIG); - if ts != 0 { - let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts); - // Ignore is ok here - let _ = std::fs::write(meta_file, content); + let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts); + for path in package_paths { + content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path)); + } + // Ignore is ok here + let _ = std::fs::write(meta_file, content); +} + +fn previous_package_files(dir: &Path) -> Vec { + let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else { + return Vec::new(); + }; + content + .lines() + .filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE)) + .map(|path| path.trim().to_owned()) + .collect() +} + +// meta.toml is plain text in a user-writable directory, and it now drives deletion, +// so the path is rebuilt from plain components rather than joined as written. A +// prefix, root or parent component would otherwise escape the extraction directory: +// Path::join replaces the base entirely when given an absolute path. +fn resolve_within(dir: &Path, relative: &str) -> Option { + use std::path::Component; + let mut path = dir.to_path_buf(); + let mut any = false; + for component in Path::new(&relative.replace('\\', "/")).components() { + match component { + Component::Normal(part) => { + // A drive-relative name like "C:x" parses as Normal, and only a + // Windows host would classify "C:/..." as a Prefix, so the colon is + // rejected outright rather than relying on the host's parser. + if part.to_string_lossy().contains(':') { + return None; + } + path.push(part); + any = true; + } + Component::CurDir => {} + _ => return None, + } + } + if any { + Some(path) + } else { + None } } +// A customer who drops a branding asset gets a package without it, and the file +// would otherwise linger in an existing extraction and keep being used. The wipe +// cannot cover this: it is keyed on the packer's build timestamp, which is now the +// same for every customer of a release. +fn remove_dropped_package_files_with( + dir: &Path, + current: &[String], + mut remove_file: F, +) -> Vec +where + F: FnMut(&Path) -> std::io::Result<()>, +{ + let keep: std::collections::HashSet = + current.iter().map(|p| normalize_path(p)).collect(); + let mut failed = Vec::new(); + for previous in previous_package_files(dir) { + if keep.contains(&normalize_path(&previous)) { + continue; + } + let Some(path) = resolve_within(dir, &previous) else { + continue; + }; + if path.is_file() { + println!("removing dropped {}", previous); + if let Err(error) = remove_file(&path) { + eprintln!("failed to remove dropped {}: {}", previous, error); + failed.push(previous); + } + } + } + failed +} + +fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec { + remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path)) +} + fn setup( reader: BinaryReader, dir: Option, @@ -71,7 +164,7 @@ fn setup( } else { // home dir if let Some(dir) = dirs::data_local_dir() { - dir.join(APP_PREFIX) + dir.join(app_dir_name(&reader.exe)) } else { eprintln!("not found data local dir"); return None; @@ -87,10 +180,12 @@ fn setup( } std::fs::remove_dir_all(&dir).ok(); } + let mut metadata_paths = reader.package_paths.clone(); + metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths)); for file in reader.files.iter() { file.write_to_file(&dir); } - write_meta(&dir, ts); + write_meta(&dir, ts, &metadata_paths); #[cfg(windows)] win::copy_runtime_broker(&dir); #[cfg(linux)] @@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec, _ui: bool) { } } -fn main() { +fn main() -> Result<(), String> { let mut args = Vec::new(); let mut arg_exe = Default::default(); let mut i = 0; @@ -193,7 +288,7 @@ fn main() { let quick_support = false; let mut ui = false; - let reader = BinaryReader::default(); + let reader = BinaryReader::new()?; if let Some(exe) = setup( reader, None, @@ -208,6 +303,7 @@ fn main() { } execute(exe, args, ui); } + Ok(()) } #[cfg(windows)] @@ -246,3 +342,27 @@ mod win { exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe") } } + +#[cfg(test)] +mod meta_tests { + use super::*; + + #[test] + fn resolve_within_rejects_paths_that_escape() { + let base = Path::new("/base"); + assert_eq!( + resolve_within(base, "./data/logo.png"), + Some(base.join("data").join("logo.png")) + ); + assert_eq!( + resolve_within(base, ".\\data\\logo.png"), + Some(base.join("data").join("logo.png")) + ); + // meta.toml is user-writable, so these must not reach remove_file. + assert_eq!(resolve_within(base, "../../etc/passwd"), None); + assert_eq!(resolve_within(base, "/etc/passwd"), None); + assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None); + assert_eq!(resolve_within(base, "."), None); + assert_eq!(resolve_within(base, ""), None); + } +} diff --git a/res/admin-roles.py b/res/admin-roles.py new file mode 100755 index 00000000000..8b1ef7f4417 --- /dev/null +++ b/res/admin-roles.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 + +import argparse +import json + +import requests + + +ROLE_TYPES = { + "global": 1, + "individual": 2, + "group": 3, +} + +PERMISSION_IDS = { + "users.view": 0x0101, + "users.create": 0x0103, + "users.invite": 0x0104, + "users.delete": 0x0105, + "users.enable_disable": 0x0106, + "users.edit_email": 0x0107, + "users.edit_password": 0x0108, + "users.edit_note": 0x0109, + "users.manage_2fa": 0x010A, + "users.force_logout": 0x010B, + "users.change_group": 0x010C, + "users.change_strategy": 0x010D, + "users.change_control_role": 0x010E, + "users.edit_display_name": 0x010F, + "devices.view": 0x0201, + "devices.enable_disable": 0x0203, + "devices.delete": 0x0204, + "devices.edit_info": 0x0205, + "devices.assign_to_user": 0x0206, + "devices.change_group": 0x0207, + "devices.change_strategy": 0x0208, + "user_groups.view": 0x0301, + "user_groups.edit": 0x0302, + "device_groups.view": 0x0401, + "device_groups.edit": 0x0402, + "device_groups.change_strategy": 0x0403, + "audits.view": 0x0501, + "audits.edit": 0x0502, + "strategies.view": 0x0601, + "strategies.edit": 0x0602, + "custom_clients.view": 0x0701, + "custom_clients.edit": 0x0702, + "control_roles.view": 0x0801, + "control_roles.edit": 0x0802, +} + +PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()} + + +def check_response(response): + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + if response.text and response.text.strip(): + try: + data = response.json() + except ValueError: + return response.text + if isinstance(data, dict) and "error" in data: + print(f"Error: {data['error']}") + exit(1) + return data + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def split_csv(value): + if value is None: + return None + return [item.strip() for item in value.split(",") if item.strip()] + + +def parse_permissions(value): + permissions = [] + for item in split_csv(value) or []: + permission = PERMISSION_IDS.get(item.lower()) + if permission is None: + try: + permission = int(item, 0) + except ValueError: + print(f"Error: Invalid permission name or ID '{item}'") + exit(1) + if permission < 0 or permission > 65535: + print(f"Error: Permission ID '{item}' is outside the 0-65535 range") + exit(1) + permissions.append(permission) + return permissions + + +def format_role_permissions(role): + permissions = role.get("permissions") + if isinstance(permissions, list): + role["permissions"] = [ + PERMISSION_NAMES.get(permission, permission) for permission in permissions + ] + return role + + +def list_roles(url, token, name=None, role_type=None, page_size=50): + params = {"pageSize": page_size} + if name is not None: + params["name"] = name + if role_type is not None: + params["type"] = ROLE_TYPES[role_type] + + roles = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/admin-roles", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing admin roles") + exit(1) + rows = data.get("data", []) + roles.extend(format_role_permissions(role) for role in rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return roles + + +def get_role(url, token, name=None, guid=None): + if guid: + response = requests.get( + f"{url}/api/admin-roles/{guid}", headers=headers_with(token) + ) + role = check_response(response) + if isinstance(role, dict): + return format_role_permissions(role) + return role + + roles = list_roles(url, token, name=name) + for role in roles: + if role.get("name") == name: + return role + return None + + +def resolve_role(url, token, name=None, guid=None): + role = get_role(url, token, name=name, guid=guid) + if role: + return role + target = guid if guid else name + print(f"Error: Admin role '{target}' not found") + exit(1) + + +def get_user_guid(url, token, name): + response = requests.get( + f"{url}/api/users", + headers=headers_with(token), + params={"name": name, "pageSize": 50, "current": 1}, + ) + data = check_response(response) + users = data.get("data", []) if isinstance(data, dict) else [] + for user in users: + if user.get("name") == name: + return user.get("guid") + return None + + +def resolve_users(url, token, users): + guids = [] + for user in users: + if len(user) == 36 and user.count("-") == 4: + guids.append(user) + continue + guid = get_user_guid(url, token, user) + if not guid: + print(f"Error: User '{user}' not found") + exit(1) + guids.append(guid) + return guids + + +def create_role( + url, + token, + name, + role_type, + permissions, + note=None, + user_groups=None, + device_groups=None, + unassigned=None, +): + payload = { + "name": name, + "type": ROLE_TYPES[role_type], + "permissions": permissions, + } + if note is not None: + payload["note"] = note + if user_groups: + payload["user_groups"] = user_groups + if device_groups: + payload["device_groups"] = device_groups + if unassigned is not None: + payload["unassigned"] = unassigned + response = requests.post( + f"{url}/api/admin-roles", headers=headers_with(token), json=payload + ) + check_response(response) + + +def update_role( + url, + token, + guid, + new_name=None, + note=None, + permissions=None, + user_groups=None, + device_groups=None, + unassigned=None, +): + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + if permissions is not None: + payload["permissions"] = permissions + if user_groups is not None: + payload["user_groups"] = user_groups + if device_groups is not None: + payload["device_groups"] = device_groups + if unassigned is not None: + payload["unassigned"] = unassigned + response = requests.put( + f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload + ) + check_response(response) + + +def delete_roles(url, token, guids): + response = requests.delete( + f"{url}/api/admin-roles", + headers=headers_with(token), + json={"guids": guids}, + ) + check_response(response) + + +def change_users(url, token, guid, users, remove=False): + method = requests.delete if remove else requests.post + response = method( + f"{url}/api/admin-roles/{guid}/users", + headers=headers_with(token), + json={"users": users}, + ) + check_response(response) + + +def view_users(url, token, role_guid, page_size=50): + params = {"admin_role_guid": role_guid, "pageSize": page_size} + users = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/users", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing users") + exit(1) + rows = data.get("data", []) + users.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return users + + +def require_role_target(parser, args): + if not args.name and not args.guid: + parser.error("one of --name or --guid is required") + + +def main(): + parser = argparse.ArgumentParser(description="Admin role manager") + parser.add_argument( + "command", + choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"], + ) + parser.add_argument("--url", required=True, help="Server URL") + parser.add_argument("--token", required=True, help="API token") + parser.add_argument("--name", help="Admin role name") + parser.add_argument("--guid", help="Admin role GUID") + parser.add_argument("--new-name", help="New admin role name") + parser.add_argument("--note", help="Role note; use an empty value to clear it") + parser.add_argument("--type", choices=ROLE_TYPES, help="Role type") + parser.add_argument( + "--permissions", + help="Comma-separated permission names or numeric IDs; use an empty value to clear", + ) + parser.add_argument( + "--user-groups", + help="Comma-separated user group names; use an empty value to clear", + ) + parser.add_argument( + "--device-groups", + help="Comma-separated device group names; use an empty value to clear", + ) + parser.add_argument("--users", help="Comma-separated user names or GUIDs") + unassigned = parser.add_mutually_exclusive_group() + unassigned.add_argument( + "--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices" + ) + unassigned.add_argument( + "--no-unassigned", + dest="unassigned", + action="store_false", + help="Exclude unassigned devices", + ) + parser.set_defaults(unassigned=None) + args = parser.parse_args() + args.url = args.url.rstrip("/") + + if args.command == "view": + if args.guid: + result = resolve_role(args.url, args.token, guid=args.guid) + else: + result = list_roles(args.url, args.token, args.name, args.type) + print(json.dumps(result, indent=2)) + return + + if args.command == "add": + if not args.name or not args.type or args.permissions is None: + parser.error("--name, --type, and --permissions are required for add") + if args.type != "group" and ( + args.user_groups is not None + or args.device_groups is not None + or args.unassigned is not None + ): + parser.error("group scope options can only be used with --type group") + create_role( + args.url, + args.token, + args.name, + args.type, + parse_permissions(args.permissions), + args.note, + split_csv(args.user_groups), + split_csv(args.device_groups), + args.unassigned, + ) + print(f"Success: Created admin role '{args.name}'") + return + + require_role_target(parser, args) + role = resolve_role(args.url, args.token, args.name, args.guid) + role_guid = role.get("guid") + role_name = role.get("name") + + if args.command == "update": + updates = [ + args.new_name, + args.note, + args.permissions, + args.user_groups, + args.device_groups, + args.unassigned, + ] + if all(value is None for value in updates): + parser.error("at least one update option is required") + if role.get("type") != ROLE_TYPES["group"] and ( + args.user_groups is not None + or args.device_groups is not None + or args.unassigned is not None + ): + parser.error("group scope options can only be used with a group role") + update_role( + args.url, + args.token, + role_guid, + args.new_name, + args.note, + parse_permissions(args.permissions) if args.permissions is not None else None, + split_csv(args.user_groups), + split_csv(args.device_groups), + args.unassigned, + ) + print(f"Success: Updated admin role '{role_name}'") + elif args.command == "delete": + delete_roles(args.url, args.token, [role_guid]) + print(f"Success: Deleted admin role '{role_name}'") + elif args.command == "view-users": + print(json.dumps(view_users(args.url, args.token, role_guid), indent=2)) + elif args.command in ("add-users", "remove-users"): + users = split_csv(args.users) + if not users: + parser.error("--users is required for add-users and remove-users") + user_guids = resolve_users(args.url, args.token, users) + remove = args.command == "remove-users" + change_users(args.url, args.token, role_guid, user_guids, remove=remove) + action = "Removed users from" if remove else "Added users to" + print(f"Success: {action} admin role '{role_name}'") + + +if __name__ == "__main__": + main() diff --git a/res/control-roles.py b/res/control-roles.py new file mode 100755 index 00000000000..3faa6a014d9 --- /dev/null +++ b/res/control-roles.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 + +import argparse +import json + +import requests + + +STATUSES = { + "disabled": 0, + "enabled": 1, +} + + +def check_response(response): + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + if response.text and response.text.strip(): + try: + data = response.json() + except ValueError: + return response.text + if isinstance(data, dict) and "error" in data: + print(f"Error: {data['error']}") + exit(1) + return data + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def split_csv(value): + if value is None: + return None + return [item.strip() for item in value.split(",") if item.strip()] + + +def list_roles(url, token, name=None, status=None, page_size=50): + params = {"pageSize": page_size} + if name is not None: + params["name"] = name + if status is not None: + params["status"] = STATUSES[status] + + roles = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/control-roles", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing control roles") + exit(1) + rows = data.get("data", []) + for role in rows: + role.pop("info", None) + roles.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return roles + + +def get_role(url, token, name=None, guid=None): + if guid: + response = requests.get( + f"{url}/api/control-roles/{guid}", headers=headers_with(token) + ) + role = check_response(response) + if isinstance(role, dict): + role.pop("info", None) + return role + + roles = list_roles(url, token, name=name) + for role in roles: + if role.get("name") == name: + return role + return None + + +def resolve_role(url, token, name=None, guid=None): + role = get_role(url, token, name=name, guid=guid) + if role: + return role + target = guid if guid else name + print(f"Error: Control role '{target}' not found") + exit(1) + + +def get_user_guid(url, token, name): + response = requests.get( + f"{url}/api/users", + headers=headers_with(token), + params={"name": name, "pageSize": 50, "current": 1}, + ) + data = check_response(response) + users = data.get("data", []) if isinstance(data, dict) else [] + for user in users: + if user.get("name") == name: + return user.get("guid") + return None + + +def resolve_users(url, token, users): + guids = [] + for user in users: + if len(user) == 36 and user.count("-") == 4: + guids.append(user) + continue + guid = get_user_guid(url, token, user) + if not guid: + print(f"Error: User '{user}' not found") + exit(1) + guids.append(guid) + return guids + + +def create_role(url, token, name, note=None): + payload = {"name": name} + if note is not None: + payload["note"] = note + response = requests.post( + f"{url}/api/control-roles", headers=headers_with(token), json=payload + ) + check_response(response) + + +def update_role(url, token, guid, new_name=None, note=None): + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + response = requests.put( + f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload + ) + check_response(response) + + +def delete_roles(url, token, guids): + response = requests.delete( + f"{url}/api/control-roles", + headers=headers_with(token), + json={"guids": guids}, + ) + check_response(response) + + +def set_status(url, token, guids, disable): + response = requests.put( + f"{url}/api/control-roles/enable", + headers=headers_with(token), + json={"guids": guids, "disable": disable}, + ) + check_response(response) + + +def change_users(url, token, guid, users, remove=False): + if remove: + endpoint = f"{url}/api/control-roles/users" + response = requests.delete( + endpoint, + headers=headers_with(token), + json={"user_guids": users}, + ) + else: + endpoint = f"{url}/api/control-roles/{guid}/users" + response = requests.post( + endpoint, + headers=headers_with(token), + json={"user_guids": users}, + ) + check_response(response) + + +def view_users(url, token, role_guid, page_size=50): + params = {"control_role_guid": role_guid, "pageSize": page_size} + users = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/users", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing users") + exit(1) + rows = data.get("data", []) + users.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return users + + +def require_role_target(parser, args): + if not args.name and not args.guid: + parser.error("one of --name or --guid is required") + + +def main(): + parser = argparse.ArgumentParser( + description="Control role manager (configure control permissions in the web console)" + ) + parser.add_argument( + "command", + choices=[ + "view", + "add", + "update", + "delete", + "enable", + "disable", + "view-users", + "assign-users", + "remove-users", + ], + ) + parser.add_argument("--url", required=True, help="Server URL") + parser.add_argument("--token", required=True, help="API token") + parser.add_argument("--name", help="Control role name") + parser.add_argument("--guid", help="Control role GUID") + parser.add_argument("--new-name", help="New control role name") + parser.add_argument("--note", help="Role note; use an empty value to clear it") + parser.add_argument("--status", choices=STATUSES, help="Status filter for view") + parser.add_argument("--users", help="Comma-separated user names or GUIDs") + args = parser.parse_args() + args.url = args.url.rstrip("/") + + if args.command == "view": + if args.guid: + result = resolve_role(args.url, args.token, guid=args.guid) + else: + result = list_roles(args.url, args.token, args.name, args.status) + print(json.dumps(result, indent=2)) + return + + if args.command == "add": + if not args.name: + parser.error("--name is required for add") + create_role(args.url, args.token, args.name, args.note) + print(f"Success: Created control role '{args.name}'") + return + + if args.command == "remove-users": + users = split_csv(args.users) + if not users: + parser.error("--users is required for remove-users") + user_guids = resolve_users(args.url, args.token, users) + change_users(args.url, args.token, None, user_guids, remove=True) + print("Success: Removed users from their control roles") + return + + require_role_target(parser, args) + role = resolve_role(args.url, args.token, args.name, args.guid) + role_guid = role.get("guid") + role_name = role.get("name") + + if args.command == "update": + if args.new_name is None and args.note is None: + parser.error("--new-name or --note is required for update") + update_role(args.url, args.token, role_guid, args.new_name, args.note) + print(f"Success: Updated control role '{role_name}'") + elif args.command == "delete": + delete_roles(args.url, args.token, [role_guid]) + print(f"Success: Deleted control role '{role_name}'") + elif args.command in ("enable", "disable"): + disable = args.command == "disable" + set_status(args.url, args.token, [role_guid], disable) + print(f"Success: {args.command.title()}d control role '{role_name}'") + elif args.command == "view-users": + print(json.dumps(view_users(args.url, args.token, role_guid), indent=2)) + elif args.command == "assign-users": + users = split_csv(args.users) + if not users: + parser.error("--users is required for assign-users") + user_guids = resolve_users(args.url, args.token, users) + change_users(args.url, args.token, role_guid, user_guids) + print(f"Success: Assigned users to control role '{role_name}'") + + +if __name__ == "__main__": + main() diff --git a/res/msi/CustomActions/Common.h b/res/msi/CustomActions/Common.h index 08302d98c36..bc9ed1ad95b 100644 --- a/res/msi/CustomActions/Common.h +++ b/res/msi/CustomActions/Common.h @@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired); namespace RemotePrinter { - VOID installUpdatePrinter(const std::wstring& installFolder); - VOID uninstallPrinter(); + // `appName` names the printer and its port. It is passed in rather than compiled + // in so that a single dll serves every custom client; an empty value keeps the + // stock "RustDesk Printer" name. + VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName); + VOID uninstallPrinter(const std::wstring& appName); } diff --git a/res/msi/CustomActions/CustomActions.cpp b/res/msi/CustomActions/CustomActions.cpp index f4780dd876c..a87743c7c81 100644 --- a/res/msi/CustomActions/CustomActions.cpp +++ b/res/msi/CustomActions/CustomActions.cpp @@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam) { do { - if (lstrcmpW(processName, processEntry.szExeFile) == 0) + if (lstrcmpiW(processName, processEntry.szExeFile) == 0) { HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID); if (process != NULL) @@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter( DWORD er = ERROR_SUCCESS; int nResult = 0; - LPWSTR installFolder = NULL; - LPWSTR pwz = NULL; LPWSTR pwzData = NULL; + std::wstring appNameValue; + std::wstring installFolderValue; hr = WcaInitialize(hInstall, "InstallPrinter"); ExitOnFailure(hr, "Failed to initialize"); @@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter( hr = WcaGetProperty(L"CustomActionData", &pwzData); ExitOnFailure(hr, "failed to get CustomActionData"); - pwz = pwzData; - hr = WcaReadStringFromCaData(&pwz, &installFolder); - ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); + // "|". Split here rather than through + // WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a + // Formatted property value cannot carry. + { + std::wstring data(pwzData); + size_t separator = data.find(L'|'); + if (separator == std::wstring::npos) + { + // A package built before the name was passed in; keep the stock name. + appNameValue.clear(); + installFolderValue = data; + } + else + { + appNameValue = data.substr(0, separator); + installFolderValue = data.substr(separator + 1); + } + } - WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder); - RemotePrinter::installUpdatePrinter(installFolder); + WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str()); + RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue); WcaLog(LOGMSG_STANDARD, "Install RD printer done"); LExit: @@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter( HRESULT hr = S_OK; DWORD er = ERROR_SUCCESS; + LPWSTR pwzData = NULL; + std::wstring appNameValue; + hr = WcaInitialize(hInstall, "UninstallPrinter"); ExitOnFailure(hr, "Failed to initialize"); + // Must match the name install used, otherwise the printer is left behind. Absent + // on packages built before this was passed in, where it was the stock name. + hr = WcaGetProperty(L"CustomActionData", &pwzData); + ExitOnFailure(hr, "failed to get CustomActionData"); + if (pwzData) + { + appNameValue = pwzData; + } + WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer"); - RemotePrinter::uninstallPrinter(); + RemotePrinter::uninstallPrinter(appNameValue); WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done"); LExit: + if (pwzData) { + ReleaseStr(pwzData); + } + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; return WcaFinalize(er); } diff --git a/res/msi/CustomActions/RemotePrinter.cpp b/res/msi/CustomActions/RemotePrinter.cpp index 767c8c82cef..532a3524bc0 100644 --- a/res/msi/CustomActions/RemotePrinter.cpp +++ b/res/msi/CustomActions/RemotePrinter.cpp @@ -18,12 +18,19 @@ namespace RemotePrinter { #define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490 + // The driver files and the driver name ship with the app under their stock names + // and stay fixed for every custom client. Only the printer and its port carry the + // app name, and that arrives at runtime so one dll serves every custom client. LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf"; - LPCWCH RD_PRINTER_PORT = L"RustDesk Printer"; - LPCWCH RD_PRINTER_NAME = L"RustDesk Printer"; LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver"; + LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk"; LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port"; + static std::wstring printerNameOf(const std::wstring &appName) + { + return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer"; + } + using FuncEnum = std::function; template using FuncOnData = std::function(const T &)>; @@ -458,8 +465,12 @@ namespace RemotePrinter // We should not check the driver version because the driver is deployed with the application. // It's better to uninstall the existing driver and install the driver from the application. // 3. Add the printer. - VOID installUpdatePrinter(const std::wstring &installFolder) + VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName) { + const std::wstring printerName = printerNameOf(appName); + const LPCWCH RD_PRINTER_NAME = printerName.c_str(); + const LPCWCH RD_PRINTER_PORT = printerName.c_str(); + const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH; if (!FileExists(infFile)) { @@ -505,13 +516,15 @@ namespace RemotePrinter } } - VOID uninstallPrinter() + VOID uninstallPrinter(const std::wstring &appName) { - deletePrinter(RD_PRINTER_NAME); + const std::wstring printerName = printerNameOf(appName); + + deletePrinter(printerName.c_str()); WcaLog(LOGMSG_STANDARD, "Deleted the printer\n"); uninstallDriver(RD_PRINTER_DRIVER_NAME); WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n"); - checkDeleteLocalPort(RD_PRINTER_PORT); + checkDeleteLocalPort(printerName.c_str()); WcaLog(LOGMSG_STANDARD, "Deleted the local port\n"); } } diff --git a/res/msi/Package/Components/RustDesk.wxs b/res/msi/Package/Components/RustDesk.wxs index 5ca5364a7e2..3041c6099e3 100644 --- a/res/msi/Package/Components/RustDesk.wxs +++ b/res/msi/Package/Components/RustDesk.wxs @@ -30,7 +30,14 @@ - + + + @@ -86,6 +93,7 @@ + diff --git a/res/msi/Package/Package.wxs b/res/msi/Package/Package.wxs index f1109ef6755..fa1660abd9c 100644 --- a/res/msi/Package/Package.wxs +++ b/res/msi/Package/Package.wxs @@ -13,6 +13,12 @@ + + + diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index cd09e499f24..ffbd478806e 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -10,7 +10,6 @@ import re import platform from pathlib import Path -from itertools import chain import shutil from xml.sax.saxutils import quoteattr @@ -67,6 +66,14 @@ def make_parser(): parser.add_argument( "-c", "--custom", action="store_true", help="Is custom client", default=False ) + parser.add_argument( + "--template", + action="store_true", + default=False, + help="Build a template to be patched per customer rather than a finished " + "package: puts the files a custom client replaces in their own cabinet, so " + "rebranding rebuilds a few hundred KB instead of the whole payload.", + ) parser.add_argument( "--conn-type", type=str, @@ -92,6 +99,43 @@ def make_parser(): return parser +# Files a custom client replaces. Kept in their own cabinet by --template so that +# rebranding rebuilds a few hundred KB instead of recompressing the whole payload. +# The app executable is handled separately: it has its own component in RustDesk.wxs. +# +# A template has to ship a placeholder for each of these so there is a File row to +# patch, but the branding assets are optional for a customer and a stock build has +# none of them at all. So each optional one installs only when its property is set, +# which the patcher does for the files a customer actually supplied. Otherwise a +# customer without a logo would install the placeholder, where today they get no +# logo at all -- the client treats a missing asset as "no logo". +PER_CUSTOMER_DISK_ID = 2 +PER_CUSTOMER_FILES = { + # relative path -> property gating installation, or None if always installed + "custom.txt": None, + "data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO", + "data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG", + "data/flutter_assets/assets/logo.png": "CC_HAS_LOGO", + "data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT", + "data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK", +} + + +def normalize_relative(relative_path): + path = relative_path.replace("\\", "/") + while path.startswith("./"): + path = path[2:] + return path.lower() + + +def is_per_customer(relative_path): + return normalize_relative(relative_path) in PER_CUSTOMER_FILES + + +def per_customer_condition(relative_path): + return PER_CUSTOMER_FILES.get(normalize_relative(relative_path)) + + def read_lines_and_start_index(file_path, tag_start, tag_end): with open(file_path, "r", encoding="utf-8") as f: lines = f.readlines() @@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end): return lines, index_start -def insert_components_between_tags(lines, index_start, app_name, dist_dir): +def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False): indent = g_indent_unit * 3 path = Path(dist_dir) idx = 1 @@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir): if subdir != ".": dir_attr = f'Subdirectory="{subdir}"' + relative = file_path.relative_to(path).as_posix() + disk_attr = "" + condition_attr = "" + if template and is_per_customer(relative): + disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"' + # Branding assets are optional, and the template only carries a + # placeholder, so install one only when the customer supplied it. + condition = per_customer_condition(relative) + if condition: + condition_attr = f' Condition="{condition} = 1"' + # Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}' # because it will cause error # "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'" to_insert_lines = f""" -{indent} -{indent}{g_indent_unit} +{indent} +{indent}{g_indent_unit} {indent} """ lines.insert(index_start + 1, to_insert_lines[1:]) @@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir): return True -def gen_auto_component(app_name, dist_dir): +def gen_auto_component(app_name, dist_dir, template=False): return gen_content_between_tags( "Package/Components/RustDesk.wxs", "", "", lambda lines, index_start: insert_components_between_tags( - lines, index_start, app_name, dist_dir + lines, index_start, app_name, dist_dir, template ), ) +def gen_media2(): + """Second cabinet holding only what a custom client replaces.""" + + def func(lines, index_start): + indent = g_indent_unit * 2 + lines.insert( + index_start + 1, + f'{indent}\n', + ) + return lines + + return gen_content_between_tags( + "Package/Package.wxs", "", "", func + ) + + +def put_app_exe_on_media2(): + """The app executable has its own component, so it is moved by name.""" + target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs") + with open(target, "r", encoding="utf-8") as f: + content = f.read() + old = '' + new = ( + '' + ) + if content.count(old) != 1: + print(f"Error: expected exactly one App.exe File element, found {content.count(old)}") + return False + with open(target, "w", encoding="utf-8") as f: + f.write(content.replace(old, new)) + return True + + def gen_pre_vars(args, dist_dir): def func(lines, index_start): upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe") @@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name): with open(file_path, "w", encoding="utf-8") as f: f.writelines(lines) -def replace_app_name_in_custom_actions(app_name): - custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions") - for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")): - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - for i, line in enumerate(lines): - line = re.sub(r"\bRustDesk\b", app_name, line) - line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver") - lines[i] = line - with open(file_path, "w", encoding="utf-8") as f: - f.writelines(lines) - def gen_upgrade_info(): def func(lines, index_start): indent = g_indent_unit * 3 @@ -478,11 +556,16 @@ def replace_component_guids_in_wxs(): if not gen_conn_type(args): sys.exit(-1) - if not gen_auto_component(app_name, dist_dir): + if args.template: + if not gen_media2(): + sys.exit(-1) + if not put_app_exe_on_media2(): + sys.exit(-1) + + if not gen_auto_component(app_name, dist_dir, args.template): sys.exit(-1) if not gen_custom_dialog_bitmaps(): sys.exit(-1) replace_app_name_in_langs(args.app_name) - replace_app_name_in_custom_actions(args.app_name) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 7a575a04ca5..00626f31cf3 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), ("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."), ("Save as", "Opslaan als"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exporteren"), + ("Export Logs", "Logboeken exporteren"), + ("Import Folder", "Map importeren"), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 892358a791d..f721942fcf3 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."), ("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."), ("Save as", "Salvar como"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportar"), + ("Export Logs", "Exportar logs"), + ("Import Folder", "Importar pasta"), ("Copy to clipboard", "Copiar para área de transferência"), ("Enable remote printer", "Habilitar impressora remota"), ("Downloading {}", "Baixando {}"),