mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-12 23:41:02 +03:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm
This commit is contained in:
17
.github/workflows/flutter-build.yml
vendored
17
.github/workflows/flutter-build.yml
vendored
@@ -405,6 +405,11 @@ jobs:
|
|||||||
# it also names payload that must never be renamed, such as librustdesk.dll
|
# it also names payload that must never be renamed, such as librustdesk.dll
|
||||||
# and drivers\RustDeskPrinterDriver.
|
# 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
|
# 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
|
# custom clients possible at all: the build agents are x64 and cannot run
|
||||||
# preprocess.py against an ARM exe.
|
# preprocess.py against an ARM exe.
|
||||||
@@ -413,8 +418,18 @@ jobs:
|
|||||||
git checkout -- res/msi
|
git checkout -- res/msi
|
||||||
cp -r ./rustdesk ./rustdesk-msi-template
|
cp -r ./rustdesk ./rustdesk-msi-template
|
||||||
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
|
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
|
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' }
|
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||||
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
||||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
<PropertyRef Id="AddRemovePropertiesFile" />
|
<PropertyRef Id="AddRemovePropertiesFile" />
|
||||||
|
|
||||||
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
||||||
|
<!--$Media2Start$-->
|
||||||
|
<!-- preprocess.py in template mode adds a second cabinet here, holding only
|
||||||
|
the files that differ per customer, so a custom client can be produced by
|
||||||
|
rebuilding that small cabinet instead of the whole package. The shipped
|
||||||
|
msi is built without template mode and keeps a single cabinet. -->
|
||||||
|
<!--$Media2End$-->
|
||||||
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
||||||
|
|
||||||
<!-- User Interface -->
|
<!-- User Interface -->
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ def make_parser():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
"-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(
|
parser.add_argument(
|
||||||
"--conn-type",
|
"--conn-type",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -90,6 +98,43 @@ def make_parser():
|
|||||||
return 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):
|
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
@@ -110,7 +155,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
|
|||||||
return lines, index_start
|
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
|
indent = g_indent_unit * 3
|
||||||
path = Path(dist_dir)
|
path = Path(dist_dir)
|
||||||
idx = 1
|
idx = 1
|
||||||
@@ -124,12 +169,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
|||||||
if subdir != ".":
|
if subdir != ".":
|
||||||
dir_attr = f'Subdirectory="{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}'
|
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
||||||
# because it will cause error
|
# because it will cause error
|
||||||
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
||||||
to_insert_lines = f"""
|
to_insert_lines = f"""
|
||||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
|
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
||||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
|
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
||||||
{indent}</Component>
|
{indent}</Component>
|
||||||
"""
|
"""
|
||||||
lines.insert(index_start + 1, to_insert_lines[1:])
|
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
|
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(
|
return gen_content_between_tags(
|
||||||
"Package/Components/RustDesk.wxs",
|
"Package/Components/RustDesk.wxs",
|
||||||
"<!--$AutoComonentStart$-->",
|
"<!--$AutoComonentStart$-->",
|
||||||
"<!--$AutoComponentEnd$-->",
|
"<!--$AutoComponentEnd$-->",
|
||||||
lambda lines, index_start: insert_components_between_tags(
|
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}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
|
||||||
|
' EmbedCab="yes" CompressionLevel="high" />\n',
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
return gen_content_between_tags(
|
||||||
|
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", 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 = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
|
||||||
|
new = (
|
||||||
|
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
|
||||||
|
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
|
||||||
|
)
|
||||||
|
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 gen_pre_vars(args, dist_dir):
|
||||||
def func(lines, index_start):
|
def func(lines, index_start):
|
||||||
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
||||||
@@ -537,7 +628,13 @@ if __name__ == "__main__":
|
|||||||
if not gen_conn_type(args):
|
if not gen_conn_type(args):
|
||||||
sys.exit(-1)
|
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)
|
sys.exit(-1)
|
||||||
|
|
||||||
if not gen_custom_dialog_bitmaps():
|
if not gen_custom_dialog_bitmaps():
|
||||||
|
|||||||
Reference in New Issue
Block a user