From 957dfe8c96203fa48628c4eea25a6d3685ca9666 Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 2 Sep 2026 21:47:03 +0800 Subject: [PATCH 1/4] feat: add admin and control role API scripts (#16035) - add admin role CRUD and membership management - add non-protobuf control role operations Signed-off-by: 21pages --- res/admin-roles.py | 417 +++++++++++++++++++++++++++++++++++++++++++ res/control-roles.py | 292 ++++++++++++++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100755 res/admin-roles.py create mode 100755 res/control-roles.py diff --git a/res/admin-roles.py b/res/admin-roles.py new file mode 100755 index 00000000000..8b1ef7f4417 --- /dev/null +++ b/res/admin-roles.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 + +import argparse +import json + +import requests + + +ROLE_TYPES = { + "global": 1, + "individual": 2, + "group": 3, +} + +PERMISSION_IDS = { + "users.view": 0x0101, + "users.create": 0x0103, + "users.invite": 0x0104, + "users.delete": 0x0105, + "users.enable_disable": 0x0106, + "users.edit_email": 0x0107, + "users.edit_password": 0x0108, + "users.edit_note": 0x0109, + "users.manage_2fa": 0x010A, + "users.force_logout": 0x010B, + "users.change_group": 0x010C, + "users.change_strategy": 0x010D, + "users.change_control_role": 0x010E, + "users.edit_display_name": 0x010F, + "devices.view": 0x0201, + "devices.enable_disable": 0x0203, + "devices.delete": 0x0204, + "devices.edit_info": 0x0205, + "devices.assign_to_user": 0x0206, + "devices.change_group": 0x0207, + "devices.change_strategy": 0x0208, + "user_groups.view": 0x0301, + "user_groups.edit": 0x0302, + "device_groups.view": 0x0401, + "device_groups.edit": 0x0402, + "device_groups.change_strategy": 0x0403, + "audits.view": 0x0501, + "audits.edit": 0x0502, + "strategies.view": 0x0601, + "strategies.edit": 0x0602, + "custom_clients.view": 0x0701, + "custom_clients.edit": 0x0702, + "control_roles.view": 0x0801, + "control_roles.edit": 0x0802, +} + +PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()} + + +def check_response(response): + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + if response.text and response.text.strip(): + try: + data = response.json() + except ValueError: + return response.text + if isinstance(data, dict) and "error" in data: + print(f"Error: {data['error']}") + exit(1) + return data + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def split_csv(value): + if value is None: + return None + return [item.strip() for item in value.split(",") if item.strip()] + + +def parse_permissions(value): + permissions = [] + for item in split_csv(value) or []: + permission = PERMISSION_IDS.get(item.lower()) + if permission is None: + try: + permission = int(item, 0) + except ValueError: + print(f"Error: Invalid permission name or ID '{item}'") + exit(1) + if permission < 0 or permission > 65535: + print(f"Error: Permission ID '{item}' is outside the 0-65535 range") + exit(1) + permissions.append(permission) + return permissions + + +def format_role_permissions(role): + permissions = role.get("permissions") + if isinstance(permissions, list): + role["permissions"] = [ + PERMISSION_NAMES.get(permission, permission) for permission in permissions + ] + return role + + +def list_roles(url, token, name=None, role_type=None, page_size=50): + params = {"pageSize": page_size} + if name is not None: + params["name"] = name + if role_type is not None: + params["type"] = ROLE_TYPES[role_type] + + roles = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/admin-roles", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing admin roles") + exit(1) + rows = data.get("data", []) + roles.extend(format_role_permissions(role) for role in rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return roles + + +def get_role(url, token, name=None, guid=None): + if guid: + response = requests.get( + f"{url}/api/admin-roles/{guid}", headers=headers_with(token) + ) + role = check_response(response) + if isinstance(role, dict): + return format_role_permissions(role) + return role + + roles = list_roles(url, token, name=name) + for role in roles: + if role.get("name") == name: + return role + return None + + +def resolve_role(url, token, name=None, guid=None): + role = get_role(url, token, name=name, guid=guid) + if role: + return role + target = guid if guid else name + print(f"Error: Admin role '{target}' not found") + exit(1) + + +def get_user_guid(url, token, name): + response = requests.get( + f"{url}/api/users", + headers=headers_with(token), + params={"name": name, "pageSize": 50, "current": 1}, + ) + data = check_response(response) + users = data.get("data", []) if isinstance(data, dict) else [] + for user in users: + if user.get("name") == name: + return user.get("guid") + return None + + +def resolve_users(url, token, users): + guids = [] + for user in users: + if len(user) == 36 and user.count("-") == 4: + guids.append(user) + continue + guid = get_user_guid(url, token, user) + if not guid: + print(f"Error: User '{user}' not found") + exit(1) + guids.append(guid) + return guids + + +def create_role( + url, + token, + name, + role_type, + permissions, + note=None, + user_groups=None, + device_groups=None, + unassigned=None, +): + payload = { + "name": name, + "type": ROLE_TYPES[role_type], + "permissions": permissions, + } + if note is not None: + payload["note"] = note + if user_groups: + payload["user_groups"] = user_groups + if device_groups: + payload["device_groups"] = device_groups + if unassigned is not None: + payload["unassigned"] = unassigned + response = requests.post( + f"{url}/api/admin-roles", headers=headers_with(token), json=payload + ) + check_response(response) + + +def update_role( + url, + token, + guid, + new_name=None, + note=None, + permissions=None, + user_groups=None, + device_groups=None, + unassigned=None, +): + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + if permissions is not None: + payload["permissions"] = permissions + if user_groups is not None: + payload["user_groups"] = user_groups + if device_groups is not None: + payload["device_groups"] = device_groups + if unassigned is not None: + payload["unassigned"] = unassigned + response = requests.put( + f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload + ) + check_response(response) + + +def delete_roles(url, token, guids): + response = requests.delete( + f"{url}/api/admin-roles", + headers=headers_with(token), + json={"guids": guids}, + ) + check_response(response) + + +def change_users(url, token, guid, users, remove=False): + method = requests.delete if remove else requests.post + response = method( + f"{url}/api/admin-roles/{guid}/users", + headers=headers_with(token), + json={"users": users}, + ) + check_response(response) + + +def view_users(url, token, role_guid, page_size=50): + params = {"admin_role_guid": role_guid, "pageSize": page_size} + users = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/users", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing users") + exit(1) + rows = data.get("data", []) + users.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return users + + +def require_role_target(parser, args): + if not args.name and not args.guid: + parser.error("one of --name or --guid is required") + + +def main(): + parser = argparse.ArgumentParser(description="Admin role manager") + parser.add_argument( + "command", + choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"], + ) + parser.add_argument("--url", required=True, help="Server URL") + parser.add_argument("--token", required=True, help="API token") + parser.add_argument("--name", help="Admin role name") + parser.add_argument("--guid", help="Admin role GUID") + parser.add_argument("--new-name", help="New admin role name") + parser.add_argument("--note", help="Role note; use an empty value to clear it") + parser.add_argument("--type", choices=ROLE_TYPES, help="Role type") + parser.add_argument( + "--permissions", + help="Comma-separated permission names or numeric IDs; use an empty value to clear", + ) + parser.add_argument( + "--user-groups", + help="Comma-separated user group names; use an empty value to clear", + ) + parser.add_argument( + "--device-groups", + help="Comma-separated device group names; use an empty value to clear", + ) + parser.add_argument("--users", help="Comma-separated user names or GUIDs") + unassigned = parser.add_mutually_exclusive_group() + unassigned.add_argument( + "--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices" + ) + unassigned.add_argument( + "--no-unassigned", + dest="unassigned", + action="store_false", + help="Exclude unassigned devices", + ) + parser.set_defaults(unassigned=None) + args = parser.parse_args() + args.url = args.url.rstrip("/") + + if args.command == "view": + if args.guid: + result = resolve_role(args.url, args.token, guid=args.guid) + else: + result = list_roles(args.url, args.token, args.name, args.type) + print(json.dumps(result, indent=2)) + return + + if args.command == "add": + if not args.name or not args.type or args.permissions is None: + parser.error("--name, --type, and --permissions are required for add") + if args.type != "group" and ( + args.user_groups is not None + or args.device_groups is not None + or args.unassigned is not None + ): + parser.error("group scope options can only be used with --type group") + create_role( + args.url, + args.token, + args.name, + args.type, + parse_permissions(args.permissions), + args.note, + split_csv(args.user_groups), + split_csv(args.device_groups), + args.unassigned, + ) + print(f"Success: Created admin role '{args.name}'") + return + + require_role_target(parser, args) + role = resolve_role(args.url, args.token, args.name, args.guid) + role_guid = role.get("guid") + role_name = role.get("name") + + if args.command == "update": + updates = [ + args.new_name, + args.note, + args.permissions, + args.user_groups, + args.device_groups, + args.unassigned, + ] + if all(value is None for value in updates): + parser.error("at least one update option is required") + if role.get("type") != ROLE_TYPES["group"] and ( + args.user_groups is not None + or args.device_groups is not None + or args.unassigned is not None + ): + parser.error("group scope options can only be used with a group role") + update_role( + args.url, + args.token, + role_guid, + args.new_name, + args.note, + parse_permissions(args.permissions) if args.permissions is not None else None, + split_csv(args.user_groups), + split_csv(args.device_groups), + args.unassigned, + ) + print(f"Success: Updated admin role '{role_name}'") + elif args.command == "delete": + delete_roles(args.url, args.token, [role_guid]) + print(f"Success: Deleted admin role '{role_name}'") + elif args.command == "view-users": + print(json.dumps(view_users(args.url, args.token, role_guid), indent=2)) + elif args.command in ("add-users", "remove-users"): + users = split_csv(args.users) + if not users: + parser.error("--users is required for add-users and remove-users") + user_guids = resolve_users(args.url, args.token, users) + remove = args.command == "remove-users" + change_users(args.url, args.token, role_guid, user_guids, remove=remove) + action = "Removed users from" if remove else "Added users to" + print(f"Success: {action} admin role '{role_name}'") + + +if __name__ == "__main__": + main() diff --git a/res/control-roles.py b/res/control-roles.py new file mode 100755 index 00000000000..3faa6a014d9 --- /dev/null +++ b/res/control-roles.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 + +import argparse +import json + +import requests + + +STATUSES = { + "disabled": 0, + "enabled": 1, +} + + +def check_response(response): + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + if response.text and response.text.strip(): + try: + data = response.json() + except ValueError: + return response.text + if isinstance(data, dict) and "error" in data: + print(f"Error: {data['error']}") + exit(1) + return data + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def split_csv(value): + if value is None: + return None + return [item.strip() for item in value.split(",") if item.strip()] + + +def list_roles(url, token, name=None, status=None, page_size=50): + params = {"pageSize": page_size} + if name is not None: + params["name"] = name + if status is not None: + params["status"] = STATUSES[status] + + roles = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/control-roles", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing control roles") + exit(1) + rows = data.get("data", []) + for role in rows: + role.pop("info", None) + roles.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return roles + + +def get_role(url, token, name=None, guid=None): + if guid: + response = requests.get( + f"{url}/api/control-roles/{guid}", headers=headers_with(token) + ) + role = check_response(response) + if isinstance(role, dict): + role.pop("info", None) + return role + + roles = list_roles(url, token, name=name) + for role in roles: + if role.get("name") == name: + return role + return None + + +def resolve_role(url, token, name=None, guid=None): + role = get_role(url, token, name=name, guid=guid) + if role: + return role + target = guid if guid else name + print(f"Error: Control role '{target}' not found") + exit(1) + + +def get_user_guid(url, token, name): + response = requests.get( + f"{url}/api/users", + headers=headers_with(token), + params={"name": name, "pageSize": 50, "current": 1}, + ) + data = check_response(response) + users = data.get("data", []) if isinstance(data, dict) else [] + for user in users: + if user.get("name") == name: + return user.get("guid") + return None + + +def resolve_users(url, token, users): + guids = [] + for user in users: + if len(user) == 36 and user.count("-") == 4: + guids.append(user) + continue + guid = get_user_guid(url, token, user) + if not guid: + print(f"Error: User '{user}' not found") + exit(1) + guids.append(guid) + return guids + + +def create_role(url, token, name, note=None): + payload = {"name": name} + if note is not None: + payload["note"] = note + response = requests.post( + f"{url}/api/control-roles", headers=headers_with(token), json=payload + ) + check_response(response) + + +def update_role(url, token, guid, new_name=None, note=None): + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + response = requests.put( + f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload + ) + check_response(response) + + +def delete_roles(url, token, guids): + response = requests.delete( + f"{url}/api/control-roles", + headers=headers_with(token), + json={"guids": guids}, + ) + check_response(response) + + +def set_status(url, token, guids, disable): + response = requests.put( + f"{url}/api/control-roles/enable", + headers=headers_with(token), + json={"guids": guids, "disable": disable}, + ) + check_response(response) + + +def change_users(url, token, guid, users, remove=False): + if remove: + endpoint = f"{url}/api/control-roles/users" + response = requests.delete( + endpoint, + headers=headers_with(token), + json={"user_guids": users}, + ) + else: + endpoint = f"{url}/api/control-roles/{guid}/users" + response = requests.post( + endpoint, + headers=headers_with(token), + json={"user_guids": users}, + ) + check_response(response) + + +def view_users(url, token, role_guid, page_size=50): + params = {"control_role_guid": role_guid, "pageSize": page_size} + users = [] + current = 0 + while True: + current += 1 + params["current"] = current + response = requests.get( + f"{url}/api/users", headers=headers_with(token), params=params + ) + data = check_response(response) + if not isinstance(data, dict): + print("Error: Unexpected response while listing users") + exit(1) + rows = data.get("data", []) + users.extend(rows) + total = data.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return users + + +def require_role_target(parser, args): + if not args.name and not args.guid: + parser.error("one of --name or --guid is required") + + +def main(): + parser = argparse.ArgumentParser( + description="Control role manager (configure control permissions in the web console)" + ) + parser.add_argument( + "command", + choices=[ + "view", + "add", + "update", + "delete", + "enable", + "disable", + "view-users", + "assign-users", + "remove-users", + ], + ) + parser.add_argument("--url", required=True, help="Server URL") + parser.add_argument("--token", required=True, help="API token") + parser.add_argument("--name", help="Control role name") + parser.add_argument("--guid", help="Control role GUID") + parser.add_argument("--new-name", help="New control role name") + parser.add_argument("--note", help="Role note; use an empty value to clear it") + parser.add_argument("--status", choices=STATUSES, help="Status filter for view") + parser.add_argument("--users", help="Comma-separated user names or GUIDs") + args = parser.parse_args() + args.url = args.url.rstrip("/") + + if args.command == "view": + if args.guid: + result = resolve_role(args.url, args.token, guid=args.guid) + else: + result = list_roles(args.url, args.token, args.name, args.status) + print(json.dumps(result, indent=2)) + return + + if args.command == "add": + if not args.name: + parser.error("--name is required for add") + create_role(args.url, args.token, args.name, args.note) + print(f"Success: Created control role '{args.name}'") + return + + if args.command == "remove-users": + users = split_csv(args.users) + if not users: + parser.error("--users is required for remove-users") + user_guids = resolve_users(args.url, args.token, users) + change_users(args.url, args.token, None, user_guids, remove=True) + print("Success: Removed users from their control roles") + return + + require_role_target(parser, args) + role = resolve_role(args.url, args.token, args.name, args.guid) + role_guid = role.get("guid") + role_name = role.get("name") + + if args.command == "update": + if args.new_name is None and args.note is None: + parser.error("--new-name or --note is required for update") + update_role(args.url, args.token, role_guid, args.new_name, args.note) + print(f"Success: Updated control role '{role_name}'") + elif args.command == "delete": + delete_roles(args.url, args.token, [role_guid]) + print(f"Success: Deleted control role '{role_name}'") + elif args.command in ("enable", "disable"): + disable = args.command == "disable" + set_status(args.url, args.token, [role_guid], disable) + print(f"Success: {args.command.title()}d control role '{role_name}'") + elif args.command == "view-users": + print(json.dumps(view_users(args.url, args.token, role_guid), indent=2)) + elif args.command == "assign-users": + users = split_csv(args.users) + if not users: + parser.error("--users is required for assign-users") + user_guids = resolve_users(args.url, args.token, users) + change_users(args.url, args.token, role_guid, user_guids) + print(f"Success: Assigned users to control role '{role_name}'") + + +if __name__ == "__main__": + main() From dfb5804dd0135aef1171c7acbde5ef9c48f818a1 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Wed, 2 Sep 2026 15:49:37 +0200 Subject: [PATCH 2/4] Update nl.rs (#16036) Re. export and import. Without detailed information, reference or examples from en.rs, de.rs or fr.rs, I have simply translated the three strings verbatim --- src/lang/nl.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 7a575a04ca5..00626f31cf3 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), ("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."), ("Save as", "Opslaan als"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exporteren"), + ("Export Logs", "Logboeken exporteren"), + ("Import Folder", "Map importeren"), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), 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 3/4] 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 409b5201da7..bf1b7610cf6 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 bcf08f38627..165d9834957 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 d5468a5dc7f..26d0b37798c 100755 --- a/libs/portable/generate.py +++ b/libs/portable/generate.py @@ -15,15 +15,29 @@ # 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 @@ def build_portable(output_folder: str, target: str): 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 @@ def build_portable(output_folder: str, target: str): 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 9effbc5893b..d488e0d1dee 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 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()) } -impl Default for BinaryReader { - fn default() -> Self { - let (files, exe) = BinaryReader::read(); - Self { files, exe } +// 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 b7ff44ec55b..284dd5ee2c7 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,15 +63,95 @@ 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( reader: BinaryReader, dir: Option, @@ -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 08302d98c36..bc9ed1ad95b 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 f4780dd876c..a87743c7c81 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 767c8c82cef..532a3524bc0 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 5ca5364a7e2..3041c6099e3 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 f1109ef6755..fa1660abd9c 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 cd09e499f24..ffbd478806e 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -10,7 +10,6 @@ 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 @@ def replace_component_guids_in_wxs(): 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) From 6dbd81045446a4fb4ef5eda21c58b0856d842fdb Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Wed, 2 Sep 2026 21:51:05 -0300 Subject: [PATCH 4/4] Translate export-related strings to Portuguese (#16038) Translate export-related strings to Portuguese --- src/lang/ptbr.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 892358a791d..f721942fcf3 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."), ("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."), ("Save as", "Salvar como"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportar"), + ("Export Logs", "Exportar logs"), + ("Import Folder", "Importar pasta"), ("Copy to clipboard", "Copiar para área de transferência"), ("Enable remote printer", "Habilitar impressora remota"), ("Downloading {}", "Baixando {}"),