diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml
index abaf63ba1..f2fb40643 100644
--- a/.github/workflows/flutter-build.yml
+++ b/.github/workflows/flutter-build.yml
@@ -405,6 +405,11 @@ jobs:
# it also names payload that must never be renamed, such as librustdesk.dll
# and drivers\RustDeskPrinterDriver.
#
+ # --template also puts the files a custom client replaces in their own
+ # cabinet, so rebranding rebuilds a few hundred KB rather than recompressing
+ # the whole payload. The shipped msi above is built without it and is
+ # unaffected.
+ #
# Building the arm64 template on the native arm64 runner is what makes ARM
# custom clients possible at all: the build agents are x64 and cannot run
# preprocess.py against an ARM exe.
@@ -413,8 +418,18 @@ jobs:
git checkout -- res/msi
cp -r ./rustdesk ./rustdesk-msi-template
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
+ # A stock build ships none of the files a custom client replaces, so the
+ # template needs a placeholder for each to have a File row to patch. The
+ # branding assets install only when the patcher says the customer supplied
+ # one, so an unused placeholder is never installed.
+ 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 -d ../../rustdesk-msi-template --app-name RDAPPNAM
+ python preprocess.py --arp --template -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
diff --git a/res/msi/Package/Package.wxs b/res/msi/Package/Package.wxs
index e11756a65..78cdf837b 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 3f52aceb8..90190d028 100644
--- a/res/msi/preprocess.py
+++ b/res/msi/preprocess.py
@@ -65,6 +65,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,
@@ -90,6 +98,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()
@@ -110,7 +155,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
@@ -124,12 +169,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:])
@@ -138,17 +194,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")
@@ -537,7 +628,13 @@ 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():