From 0fd1a0eecba9ff296d8e352fddef6d41fe9ff078 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:14:03 +0800 Subject: [PATCH] Custom client no rebuild (#15774) * feat(portable): load per-customer payload from a PE resource Customizing a Windows client recompiled the packer for every customer, because data.bin was baked in with include_bytes!. The generic payload is identical across customers, so only the small per-customer delta needs to vary: the branded runner exe, custom.txt and the icons. The packer now also reads an RDPKG RCDATA resource holding a second blob in the same format, and folds it over the compiled-in payload. A build can then inject that resource into a prebuilt template instead of running cargo. The executable to launch comes from the package trailer, and the extraction directory follows its stem, which replaces the sed of APP_PREFIX. Where the executable itself is not customized (sciter x86) it stays in the generic payload and is only renamed, so the merge covers both shapes. custom.txt keeps being written to disk next to the app: that is what the client reads at startup and what the updater stages so a customization survives an upgrade to a stock build. Also fixes generate.py restoring os.curdir (the literal ".") instead of the previous working directory, which left it inside the source folder. CI: ship windows-aarch64 in the unsigned tarball, so ARM custom clients have a template to build from. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * ci: publish msi templates for custom client builds Custom clients rebuild the msi through WiX for every customer, though the package only differs by the app name, a few GUIDs and four files. Build the msi once more per release with a __RDAPPNAME__ placeholder and ship it unsigned in the unsigned tarball, so a customer's build can patch it rather than run msbuild. It stays unsigned because patching would invalidate a signature anyway. Doing this in CI is what makes ARM custom clients possible: preprocess.py runs the packaged exe to read its version and build date, so an arm64 msi can only be produced on a native arm64 machine, which the runner already is and the build agents are not. Patching runs no exe, so an x64 agent can then patch the arm64 template. preprocess.py rewrites res/msi in place and locates the app as .exe inside the dist, so the tree is reset around the second build and the dist copy is renamed to match. Sciter x86 ships no msi and is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * refactor(msi): pass the app name to the printer custom actions preprocess.py rewrote the CustomActions sources per customer so the printer carried the app name, which meant the dll was recompiled for every custom client and, worse, left the app name baked into a compiled binary. Pass it through CustomActionData instead. Only the printer and its port ever varied: the INF path and the driver name ship under their stock names and preprocess.py already forced the driver name back to RustDesk, so a single build of the dll now serves every custom client. Both actions treat the name as optional and fall back to the stock name, so a package built before this still installs and uninstalls its printer. This also unblocks patching a prebuilt msi template, which cannot work while a compiled dll contains the app name: replacing a string inside a PE would shift everything after it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * ci: use an 8.3-safe placeholder for the msi template WiX derives a short name for any name that is not valid 8.3, and a patch cannot rewrite a truncated placeholder, so a long placeholder would leave the package's short names pointing at it. RDAPPNAM is eight characters like "RustDesk" and needs no short name, keeping the template as close to the shipped package as the mechanism allows. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * feat(msi): give a template its own cabinet for per-customer files Rebranding recompressed the whole ~100MB payload because one cabinet held everything. In template mode preprocess.py puts the handful of files a custom client replaces on a second cabinet, so a patch rebuilds a few hundred KB and leaves the payload cabinet alone. The shipped msi is built without template mode and keeps its single cabinet. The branding assets need conditional components. A stock build ships none of them -- there is no icon.ico, icon.png or logo*.png, only icon.svg -- so the template has to carry placeholders for the File rows to exist, and a customer supplies whichever they want. Installing a placeholder unconditionally would give a customer with no logo a placeholder image, where today a missing asset means no logo at all: the client tries each candidate and treats the failure as absence. So each optional asset installs only when its property says the customer supplied one. CI creates those placeholders and builds the template with the new mode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * ci: build the msi template with a sentinel revision preprocess.py appends a build-time revision as the fourth version field, so a template built without one would bake the CI clock into every customer's package. Revision 0 marks the field as the patcher's to fill in, and makes the template deterministic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * fix(portable): delete files a later package no longer carries The extraction directory is wiped only when the packer's compiled-in timestamp changes. That used to be per customer, because generate.py ran for each build; now the packer is compiled once per release, so every customer and every rebuild within a release share one timestamp and nothing is ever wiped. A customer who removes their logo and rebuilds would therefore keep showing it: the new package simply omits logo.png, and md5 skipping only covers files that are still present. Record the package's paths in the extraction's meta file and delete the ones a later package drops. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * fix(portable): build the dropped-file path from plain components meta.toml lives in a user-writable directory and now drives deletion, but the traversal guard tested the normalised string while the join used the raw one. Path::join replaces the base outright when handed an absolute path, so an edited meta.toml could point remove_file anywhere. The path is now rebuilt from Normal components only. A colon is rejected explicitly rather than left to the host's parser: a drive-relative "C:x" parses as a Normal component everywhere, and only a Windows host reads "C:/..." as a prefix, so the same input escaped when the logic was exercised off-Windows -- which is what the new test catches. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * fix(msi): pass the printer name in a format the custom action can read [~] is MSI's escape for a NUL character, not the delimiter WcaReadStringFromCaData splits on -- that is a literal wide char 128, which a Formatted property value cannot carry -- and WcaGetProperty returns a null-terminated string anyway. So the second field was unreachable: InstallPrinter always fell back to the stock name and installed a printer and port called "RustDesk Printer" inside a customer's branded package, while UninstallPrinter, whose data is a single field and parsed fine, went looking for "Acme Printer" and left the real one behind for good. Both actions now read CustomActionData directly and split on a character that cannot occur in a Windows path or in a validated app name. A package built before this carries no separator and keeps the stock name, as it did. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm * fix(portable): retry failed stale branding cleanup Signed-off-by: fufesou * fix(portable): reject malformed RDPKG resources Distinguish an absent customer package from an invalid resource and propagate package errors instead of launching the stock payload. Signed-off-by: fufesou * refact: format 2 files Signed-off-by: fufesou * fix(msi): match process names case-insensitively during uninstall Signed-off-by: fufesou * fix(custom-client): validate portable exclusion and MSI action data Fail when --exclude-exe does not match a file, and propagate MSI CustomActionData read failures while preserving legacy fallback behavior. Signed-off-by: fufesou * fix: generate.py, exclude-exe Signed-off-by: fufesou * Revert "fix: generate.py, exclude-exe" This reverts commit 5104664e95a497bbaa3b0df27896074377436a5f. * fix: simple path fix in generate.py Signed-off-by: fufesou * Remove useless comments Signed-off-by: fufesou * fix(portable): remove expect() anyway Signed-off-by: fufesou * fix(portable): validate executable path boundaries Reject executables outside the source folder and reuse the package path normalization logic during stale file cleanup. Signed-off-by: fufesou * fix, remove useless file Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Opus 4.8 Co-authored-by: fufesou --- .github/workflows/flutter-build.yml | 68 ++++- libs/portable/Cargo.toml | 2 +- libs/portable/generate.py | 60 +++- libs/portable/src/bin_reader.rs | 384 ++++++++++++++++++++---- libs/portable/src/main.rs | 140 ++++++++- res/msi/CustomActions/Common.h | 7 +- res/msi/CustomActions/CustomActions.cpp | 49 ++- res/msi/CustomActions/RemotePrinter.cpp | 25 +- res/msi/Package/Components/RustDesk.wxs | 10 +- res/msi/Package/Package.wxs | 6 + res/msi/preprocess.py | 123 ++++++-- 11 files changed, 756 insertions(+), 118 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 409b5201d..bf1b7610c 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 bcf08f386..165d98349 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 d5468a5dc..26d0b3779 100755 --- a/libs/portable/generate.py +++ b/libs/portable/generate.py @@ -15,15 +15,29 @@ encoding = 'utf-8' # 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 @@ if __name__ == '__main__': 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 @@ if __name__ == '__main__': 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 9effbc589..d488e0d1d 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 Default for BinaryReader { - fn default() -> Self { - let (files, exe) = BinaryReader::read(); - Self { files, exe } +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()) +} + +// 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 b7ff44ec5..284dd5ee2 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,13 +63,93 @@ 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( @@ -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/msi/CustomActions/Common.h b/res/msi/CustomActions/Common.h index 08302d98c..bc9ed1ad9 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 f4780dd87..a87743c7c 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 767c8c82c..532a3524b 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 5ca5364a7..3041c6099 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 f1109ef67..fa1660abd 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 cd09e499f..ffbd47880 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -10,7 +10,6 @@ import subprocess 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 @@ if __name__ == "__main__": 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)