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():