mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 15:41:23 +03:00
* 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 <noreply@anthropic.com>
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 <app-name>.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fBdTwziR5BHTkSz7Tzcm
* fix(portable): retry failed stale branding cleanup
Signed-off-by: fufesou <linlong1266@gmail.com>
* 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 <linlong1266@gmail.com>
* refact: format 2 files
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(msi): match process names case-insensitively during uninstall
Signed-off-by: fufesou <linlong1266@gmail.com>
* 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 <linlong1266@gmail.com>
* fix: generate.py, exclude-exe
Signed-off-by: fufesou <linlong1266@gmail.com>
* Revert "fix: generate.py, exclude-exe"
This reverts commit 5104664e95.
* fix: simple path fix in generate.py
Signed-off-by: fufesou <linlong1266@gmail.com>
* Remove useless comments
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(portable): remove expect() anyway
Signed-off-by: fufesou <linlong1266@gmail.com>
* 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 <linlong1266@gmail.com>
* fix, remove useless file
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
572 lines
19 KiB
Python
572 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import json
|
|
import sys
|
|
import uuid
|
|
import argparse
|
|
import datetime
|
|
import subprocess
|
|
import re
|
|
import platform
|
|
from pathlib import Path
|
|
import shutil
|
|
from xml.sax.saxutils import quoteattr
|
|
|
|
g_indent_unit = "\t"
|
|
g_version = ""
|
|
g_build_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
|
|
# Replace the following links with your own in the custom arp properties.
|
|
# https://learn.microsoft.com/en-us/windows/win32/msi/property-reference
|
|
g_arpsystemcomponent = {
|
|
"Comments": {
|
|
"msi": "ARPCOMMENTS",
|
|
"t": "string",
|
|
"v": "!(loc.AR_Comment)",
|
|
},
|
|
"Contact": {
|
|
"msi": "ARPCONTACT",
|
|
"v": "https://github.com/rustdesk/rustdesk",
|
|
},
|
|
"HelpLink": {
|
|
"msi": "ARPHELPLINK",
|
|
"v": "https://github.com/rustdesk/rustdesk/issues/",
|
|
},
|
|
"ReadMe": {
|
|
"msi": "ARPREADME",
|
|
"v": "https://github.com/rustdesk/rustdesk",
|
|
},
|
|
}
|
|
|
|
def default_revision_version():
|
|
return int(datetime.datetime.now().timestamp() / 60)
|
|
|
|
def make_parser():
|
|
parser = argparse.ArgumentParser(description="Msi preprocess script.")
|
|
parser.add_argument(
|
|
"-d",
|
|
"--dist-dir",
|
|
type=str,
|
|
default="../../rustdesk",
|
|
help="The dist directory to install.",
|
|
)
|
|
parser.add_argument(
|
|
"--arp",
|
|
action="store_true",
|
|
help="Deprecated; native MSI ARP registration is always used.",
|
|
default=False,
|
|
)
|
|
parser.add_argument(
|
|
"--custom-arp",
|
|
type=str,
|
|
default="{}",
|
|
help='Custom arp properties, e.g. \'{"Comments": {"msi": "ARPCOMMENTS", "v": "Remote control application."}}\'',
|
|
)
|
|
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,
|
|
default="",
|
|
help='Connection type, e.g. "incoming", "outgoing". Default is empty, means incoming-outgoing',
|
|
)
|
|
parser.add_argument(
|
|
"--app-name", type=str, default="RustDesk", help="The app name."
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--version", type=str, default="", help="The app version."
|
|
)
|
|
parser.add_argument(
|
|
"--revision-version", type=int, default=default_revision_version(), help="The revision version."
|
|
)
|
|
parser.add_argument(
|
|
"-m",
|
|
"--manufacturer",
|
|
type=str,
|
|
default="Purslane Tech Pte. Ltd.",
|
|
help="The app manufacturer.",
|
|
)
|
|
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()
|
|
index_start = -1
|
|
index_end = -1
|
|
for i, line in enumerate(lines):
|
|
if tag_start in line:
|
|
index_start = i
|
|
if tag_end in line:
|
|
index_end = i
|
|
|
|
if index_start == -1:
|
|
print(f'Error: start tag "{tag_start}" not found')
|
|
return None, None
|
|
if index_end == -1:
|
|
print(f'Error: end tag "{tag_end}" not found')
|
|
return None, None
|
|
return lines, index_start
|
|
|
|
|
|
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
|
|
for file_path in path.glob("**/*"):
|
|
if file_path.is_file():
|
|
if file_path.name.lower() == f"{app_name}.exe".lower():
|
|
continue
|
|
|
|
subdir = str(file_path.parent.relative_to(path))
|
|
dir_attr = ""
|
|
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}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
|
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
|
{indent}</Component>
|
|
"""
|
|
lines.insert(index_start + 1, to_insert_lines[1:])
|
|
index_start += 1
|
|
idx += 1
|
|
return True
|
|
|
|
|
|
def gen_auto_component(app_name, dist_dir, template=False):
|
|
return gen_content_between_tags(
|
|
"Package/Components/RustDesk.wxs",
|
|
"<!--$AutoComonentStart$-->",
|
|
"<!--$AutoComponentEnd$-->",
|
|
lambda lines, index_start: insert_components_between_tags(
|
|
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 func(lines, index_start):
|
|
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
|
|
|
indent = g_indent_unit * 1
|
|
to_insert_lines = [
|
|
f'{indent}<?define Version="{g_version}" ?>\n',
|
|
f'{indent}<?define Manufacturer="{args.manufacturer}" ?>\n',
|
|
f'{indent}<?define Product="{args.app_name}" ?>\n',
|
|
f'{indent}<?define Description="{args.app_name} Installer" ?>\n',
|
|
f'{indent}<?define ProductLower="{args.app_name.lower()}" ?>\n',
|
|
f'{indent}<?define RegKeyRoot=".$(var.ProductLower)" ?>\n',
|
|
f'{indent}<?define RegKeyInstall="$(var.RegKeyRoot)\\Install" ?>\n',
|
|
f'{indent}<?define BuildDir="{dist_dir}" ?>\n',
|
|
f'{indent}<?define BuildDate="{g_build_date}" ?>\n',
|
|
"\n",
|
|
f"{indent}<!-- The UpgradeCode must be consistent for each product. ! -->\n"
|
|
f'{indent}<?define UpgradeCode = "{upgrade_code}" ?>\n',
|
|
]
|
|
|
|
for i, line in enumerate(to_insert_lines):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Includes.wxi", "<!--$PreVarsStart$-->", "<!--$PreVarsEnd$-->", func
|
|
)
|
|
|
|
|
|
def replace_app_name_in_langs(app_name):
|
|
langs_dir = Path(sys.argv[0]).parent.joinpath("Package/Language")
|
|
for file_path in langs_dir.glob("*.wxl"):
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
for i, line in enumerate(lines):
|
|
lines[i] = line.replace("RustDesk", app_name)
|
|
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
|
|
|
|
vs = g_version.split(".")
|
|
major = vs[0]
|
|
upgrade_id = uuid.uuid4()
|
|
to_insert_lines = [
|
|
f'{indent}<Upgrade Id="{upgrade_id}">\n',
|
|
f'{indent}{g_indent_unit}<UpgradeVersion Property="OLD_VERSION_FOUND" Minimum="{major}.0.0" Maximum="{major}.99.99" IncludeMinimum="yes" IncludeMaximum="yes" OnlyDetect="no" IgnoreRemoveFailure="yes" MigrateFeatures="yes" />\n',
|
|
f"{indent}</Upgrade>\n",
|
|
]
|
|
|
|
for i, line in enumerate(to_insert_lines):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Fragments/Upgrades.wxs",
|
|
"<!--$UpgradeStart$-->",
|
|
"<!--$UpgradeEnd$-->",
|
|
func,
|
|
)
|
|
|
|
|
|
def gen_custom_dialog_bitmaps():
|
|
def func(lines, index_start):
|
|
indent = g_indent_unit * 2
|
|
|
|
# https://wixtoolset.org/docs/tools/wixext/wixui/#customizing-a-dialog-set
|
|
vars = [
|
|
"WixUIBannerBmp",
|
|
"WixUIDialogBmp",
|
|
"WixUIExclamationIco",
|
|
"WixUIInfoIco",
|
|
"WixUINewIco",
|
|
"WixUIUpIco",
|
|
]
|
|
to_insert_lines = []
|
|
for var in vars:
|
|
if Path(f"Package/Resources/{var}.bmp").exists():
|
|
to_insert_lines.append(
|
|
f'{indent}<WixVariable Id="{var}" Value="Resources\\{var}.bmp" />\n'
|
|
)
|
|
|
|
for i, line in enumerate(to_insert_lines):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Package.wxs",
|
|
"<!--$CustomBitmapsStart$-->",
|
|
"<!--$CustomBitmapsEnd$-->",
|
|
func,
|
|
)
|
|
|
|
|
|
def gen_native_arp_properties():
|
|
def func(lines, index_start):
|
|
indent = g_indent_unit * 2
|
|
|
|
lines_new = []
|
|
lines_new.append(
|
|
f"{indent}<!--https://learn.microsoft.com/en-us/windows/win32/msi/property-reference-->\n"
|
|
)
|
|
for _, v in g_arpsystemcomponent.items():
|
|
if "msi" in v and "v" in v:
|
|
lines_new.append(
|
|
f'{indent}<Property Id={quoteattr(str(v["msi"]))} '
|
|
f'Value={quoteattr(str(v["v"]))} />\n'
|
|
)
|
|
|
|
for i, line in enumerate(lines_new):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Fragments/AddRemoveProperties.wxs",
|
|
"<!--$ArpStart$-->",
|
|
"<!--$ArpEnd$-->",
|
|
func,
|
|
)
|
|
|
|
|
|
def gen_install_state_values():
|
|
def func(lines, index_start):
|
|
indent = g_indent_unit * 5
|
|
lines_new = []
|
|
for name, value in g_arpsystemcomponent.items():
|
|
if "msi" not in value and "v" in value:
|
|
value_type = value.get("t", "string")
|
|
lines_new.append(
|
|
f'{indent}<RegistryValue Type={quoteattr(str(value_type))} '
|
|
f'Name={quoteattr(str(name))} Value={quoteattr(str(value["v"]))} />\n'
|
|
)
|
|
|
|
for i, line in enumerate(lines_new):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Components/Regs.wxs",
|
|
"<!--$InstallStateStart$-->",
|
|
"<!--$InstallStateEnd$-->",
|
|
func,
|
|
)
|
|
|
|
|
|
def gen_custom_ARPSYSTEMCOMPONENT(args, _dist_dir):
|
|
try:
|
|
custom_arp = dict(json.loads(args.custom_arp))
|
|
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
print(f"Failed to decode custom arp: {e}")
|
|
return False
|
|
|
|
if any(not isinstance(value, dict) for value in custom_arp.values()):
|
|
print("Custom arp entries must be objects.")
|
|
return False
|
|
|
|
if any(
|
|
isinstance(value, dict) and value.get("msi") == "ARPSYSTEMCOMPONENT"
|
|
for value in custom_arp.values()
|
|
):
|
|
print("ARPSYSTEMCOMPONENT is not allowed; native MSI ARP registration must remain visible.")
|
|
return False
|
|
|
|
g_arpsystemcomponent.update(custom_arp)
|
|
|
|
if not gen_native_arp_properties():
|
|
return False
|
|
return gen_install_state_values()
|
|
|
|
def gen_conn_type(args):
|
|
def func(lines, index_start):
|
|
indent = g_indent_unit * 3
|
|
|
|
lines_new = []
|
|
if args.conn_type != "":
|
|
lines_new.append(
|
|
f"""{indent}<Property Id="CC_CONNECTION_TYPE" Value="{args.conn_type}" />\n"""
|
|
)
|
|
|
|
for i, line in enumerate(lines_new):
|
|
lines.insert(index_start + i + 1, line)
|
|
return lines
|
|
|
|
return gen_content_between_tags(
|
|
"Package/Fragments/AddRemoveProperties.wxs",
|
|
"<!--$CustomClientPropsStart$-->",
|
|
"<!--$CustomClientPropsEnd$-->",
|
|
func,
|
|
)
|
|
|
|
def gen_content_between_tags(filename, tag_start, tag_end, func):
|
|
target_file = Path(sys.argv[0]).parent.joinpath(filename)
|
|
lines, index_start = read_lines_and_start_index(target_file, tag_start, tag_end)
|
|
if lines is None:
|
|
return False
|
|
|
|
func(lines, index_start)
|
|
|
|
with open(target_file, "w", encoding="utf-8") as f:
|
|
f.writelines(lines)
|
|
|
|
return True
|
|
|
|
|
|
def prepare_resources():
|
|
icon_src = Path(sys.argv[0]).parent.joinpath("../icon.ico")
|
|
icon_dst = Path(sys.argv[0]).parent.joinpath("Package/Resources/icon.ico")
|
|
if icon_src.exists():
|
|
icon_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(icon_src, icon_dst)
|
|
return True
|
|
else:
|
|
# unreachable
|
|
print(f"Error: icon.ico not found in {icon_src}")
|
|
return False
|
|
|
|
|
|
def init_global_vars(dist_dir, app_name, args):
|
|
dist_app = dist_dir.joinpath(app_name + ".exe")
|
|
|
|
def read_process_output(args):
|
|
process = subprocess.Popen(
|
|
f"{dist_app} {args}",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
shell=True,
|
|
)
|
|
output, _ = process.communicate()
|
|
return output.decode("utf-8").strip()
|
|
|
|
global g_version
|
|
global g_build_date
|
|
g_version = args.version.replace("-", ".")
|
|
if g_version == "":
|
|
g_version = read_process_output("--version")
|
|
version_pattern = re.compile(r"\d+\.\d+\.\d+.*")
|
|
if not version_pattern.match(g_version):
|
|
print(f"Error: version {g_version} not found in {dist_app}")
|
|
return False
|
|
if g_version.count(".") == 2:
|
|
# https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Private.CoreLib/src/System/Version.cs
|
|
if args.revision_version < 0 or args.revision_version > 2147483647:
|
|
raise ValueError(f"Invalid revision version: {args.revision_version}")
|
|
g_version = f"{g_version}.{args.revision_version}"
|
|
|
|
g_build_date = read_process_output("--build-date")
|
|
build_date_pattern = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}")
|
|
if not build_date_pattern.match(g_build_date):
|
|
print(f"Error: build date {g_build_date} not found in {dist_app}")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def update_license_file(app_name):
|
|
if app_name == "RustDesk":
|
|
return
|
|
license_file = Path(sys.argv[0]).parent.joinpath("Package/License.rtf")
|
|
with open(license_file, "r", encoding="utf-8") as f:
|
|
license_content = f.read()
|
|
license_content = license_content.replace("website rustdesk.com and other ", "")
|
|
license_content = license_content.replace("RustDesk", app_name)
|
|
license_content = re.sub(r"Purslane(?: Tech Pte\.)? Ltd", app_name, license_content, flags=re.IGNORECASE)
|
|
with open(license_file, "w", encoding="utf-8") as f:
|
|
f.write(license_content)
|
|
|
|
|
|
def replace_component_guids_in_wxs():
|
|
langs_dir = Path(sys.argv[0]).parent.joinpath("Package")
|
|
for file_path in langs_dir.glob("**/*.wxs"):
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
|
|
# <Component Id="Product.Registry.DefaultIcon" Guid="6DBF2690-0955-4C6A-940F-634DDA503F49">
|
|
for i, line in enumerate(lines):
|
|
match = re.search(r'Component.+Guid="([^"]+)"', line)
|
|
if match:
|
|
lines[i] = re.sub(r'Guid="[^"]+"', f'Guid="{uuid.uuid4()}"', line)
|
|
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
f.writelines(lines)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = make_parser()
|
|
args = parser.parse_args()
|
|
|
|
app_name = args.app_name
|
|
dist_dir = Path(sys.argv[0]).parent.joinpath(args.dist_dir).resolve()
|
|
|
|
if not prepare_resources():
|
|
sys.exit(-1)
|
|
|
|
if not init_global_vars(dist_dir, app_name, args):
|
|
sys.exit(-1)
|
|
|
|
update_license_file(app_name)
|
|
|
|
if not gen_pre_vars(args, dist_dir):
|
|
sys.exit(-1)
|
|
|
|
if app_name != "RustDesk":
|
|
replace_component_guids_in_wxs()
|
|
|
|
if not gen_upgrade_info():
|
|
sys.exit(-1)
|
|
|
|
if not gen_custom_ARPSYSTEMCOMPONENT(args, dist_dir):
|
|
sys.exit(-1)
|
|
|
|
if not gen_conn_type(args):
|
|
sys.exit(-1)
|
|
|
|
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)
|