mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-14 16:31:03 +03:00
Compare commits
4 Commits
hdr-tonema
...
0fd1a0eecb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd |
68
.github/workflows/flutter-build.yml
vendored
68
.github/workflows/flutter-build.yml
vendored
@@ -389,6 +389,54 @@ jobs:
|
|||||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||||
sha256sum ../../SignOutput/rustdesk-*.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
|
||||||
|
# <app-name>.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
|
- name: Sign rustdesk self-extracted file
|
||||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -925,15 +973,33 @@ jobs:
|
|||||||
name: rustdesk-unsigned-windows-x86_64
|
name: rustdesk-unsigned-windows-x86_64
|
||||||
path: ./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
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: rustdesk-unsigned-windows-x86
|
name: rustdesk-unsigned-windows-x86
|
||||||
path: ./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
|
- name: Combine unsigned app
|
||||||
run: |
|
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
|
- name: Publish unsigned app
|
||||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ build = "build.rs"
|
|||||||
brotli = "3.4"
|
brotli = "3.4"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
md5 = "0.7"
|
md5 = "0.7"
|
||||||
winapi = { version = "0.3", features = ["winbase"] }
|
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows = { version = "0.61", features = [
|
windows = { version = "0.61", features = [
|
||||||
|
|||||||
@@ -15,15 +15,29 @@ encoding = 'utf-8'
|
|||||||
# output: {path: (compressed_data, file_md5)}
|
# 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()
|
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)
|
os.chdir(folder)
|
||||||
for root, _, files in os.walk('.'):
|
for root, _, files in os.walk('.'):
|
||||||
# remove ./
|
# remove ./
|
||||||
for f in files:
|
for f in files:
|
||||||
md5_generator = md5()
|
md5_generator = md5()
|
||||||
full_path = os.path.join(root, f)
|
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}...")
|
print(f"Processing {full_path}...")
|
||||||
f = open(full_path, "rb")
|
f = open(full_path, "rb")
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
|
|||||||
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
||||||
res[full_path] = (content_compressed, md5_code)
|
res[full_path] = (content_compressed, md5_code)
|
||||||
os.chdir(curdir)
|
os.chdir(curdir)
|
||||||
|
if skip and not excluded:
|
||||||
|
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
|
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:
|
with open(output_path, "wb") as f:
|
||||||
f.write("rustdesk".encode(encoding=encoding))
|
f.write("rustdesk".encode(encoding=encoding))
|
||||||
for path in md5_table.keys():
|
for path in md5_table.keys():
|
||||||
@@ -92,6 +111,14 @@ if __name__ == '__main__':
|
|||||||
help="the target used by cargo")
|
help="the target used by cargo")
|
||||||
parser.add_option("-l", "--level", dest="level", type="int",
|
parser.add_option("-l", "--level", dest="level", type="int",
|
||||||
help="compression level, default is 11, highest", default=11)
|
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()
|
(options, args) = parser.parse_args()
|
||||||
folder = options.folder or './rustdesk'
|
folder = options.folder or './rustdesk'
|
||||||
output_folder = os.path.abspath(options.output_folder or './')
|
output_folder = os.path.abspath(options.output_folder or './')
|
||||||
@@ -100,14 +127,29 @@ if __name__ == '__main__':
|
|||||||
options.executable = 'rustdesk.exe'
|
options.executable = 'rustdesk.exe'
|
||||||
if not options.executable.startswith(folder):
|
if not options.executable.startswith(folder):
|
||||||
options.executable = folder + '/' + options.executable
|
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)
|
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")
|
print("The executable must locate in source folder")
|
||||||
exit(-1)
|
exit(-1)
|
||||||
exe = '.' + exe[len(os.path.abspath(folder)):]
|
exe = '.' + exe[len(folder_path):]
|
||||||
print("Executable path: " + exe)
|
print("Executable path: " + exe)
|
||||||
print("Compression level: " + str(options.level))
|
print("Compression level: " + str(options.level))
|
||||||
md5_table = generate_md5_table(folder, options.level)
|
md5_table = generate_md5_table(
|
||||||
write_package_metadata(md5_table, output_folder, exe)
|
folder, options.level, exe if options.exclude_exe else None)
|
||||||
write_app_metadata(output_folder)
|
if options.package:
|
||||||
build_portable(output_folder, options.target)
|
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)
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
fs::{self},
|
fs::{self},
|
||||||
io::{Cursor, Read},
|
io::{Cursor, Read},
|
||||||
path::Path,
|
path::Path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The generic payload, shared by every customer and compiled in once per release.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
|
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
|
// 4bytes
|
||||||
const LENGTH: usize = 4;
|
const LENGTH: usize = 4;
|
||||||
|
const IDENTIFIER: &[u8] = b"rustdesk";
|
||||||
const IDENTIFIER_LENGTH: usize = 8;
|
const IDENTIFIER_LENGTH: usize = 8;
|
||||||
const MD5_LENGTH: usize = 32;
|
const MD5_LENGTH: usize = 32;
|
||||||
const BUF_SIZE: usize = 4096;
|
const BUF_SIZE: usize = 4096;
|
||||||
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
|
|||||||
pub(crate) struct BinaryReader {
|
pub(crate) struct BinaryReader {
|
||||||
pub files: Vec<BinaryData>,
|
pub files: Vec<BinaryData>,
|
||||||
pub exe: String,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BinaryReader {
|
impl BinaryReader {
|
||||||
fn default() -> Self {
|
pub fn new() -> Result<Self, String> {
|
||||||
let (files, exe) = BinaryReader::read();
|
let package = read_package()?;
|
||||||
Self { files, exe }
|
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<BinaryData>, String),
|
||||||
|
package: (Vec<BinaryData>, String),
|
||||||
|
) -> (Vec<BinaryData>, 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
|
||||||
|
// `<appname>.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<String> = 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<u32> {
|
||||||
|
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<BinaryData>, 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<BinaryData>, String), String> {
|
||||||
|
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||||
|
Ok(Default::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, 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<BinaryData>, String), String> {
|
||||||
|
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||||
|
Ok(Default::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<u16> = 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 {
|
impl BinaryReader {
|
||||||
fn read() -> (Vec<BinaryData>, 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)]
|
#[cfg(linux)]
|
||||||
pub fn configure_permission(&self, prefix: &Path) {
|
pub fn configure_permission(&self, prefix: &Path) {
|
||||||
use std::os::unix::prelude::PermissionsExt;
|
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<String> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bin_reader::BinaryReader;
|
use bin_reader::{normalize_path, BinaryReader};
|
||||||
|
|
||||||
pub mod bin_reader;
|
pub mod bin_reader;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
|
|||||||
const APP_METADATA: &[u8] = &[];
|
const APP_METADATA: &[u8] = &[];
|
||||||
const APP_METADATA_CONFIG: &str = "meta.toml";
|
const APP_METADATA_CONFIG: &str = "meta.toml";
|
||||||
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
|
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
|
||||||
|
const META_LINE_PREFIX_FILE: &str = "file = ";
|
||||||
const APP_PREFIX: &str = "rustdesk";
|
const APP_PREFIX: &str = "rustdesk";
|
||||||
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
|
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
|
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 {
|
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||||
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
|
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
|
||||||
return true;
|
return true;
|
||||||
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
|||||||
false
|
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);
|
let meta_file = dir.join(APP_METADATA_CONFIG);
|
||||||
if ts != 0 {
|
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||||
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
|
for path in package_paths {
|
||||||
// Ignore is ok here
|
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
|
||||||
let _ = std::fs::write(meta_file, content);
|
|
||||||
}
|
}
|
||||||
|
// Ignore is ok here
|
||||||
|
let _ = std::fs::write(meta_file, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_package_files(dir: &Path) -> Vec<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<F>(
|
||||||
|
dir: &Path,
|
||||||
|
current: &[String],
|
||||||
|
mut remove_file: F,
|
||||||
|
) -> Vec<String>
|
||||||
|
where
|
||||||
|
F: FnMut(&Path) -> std::io::Result<()>,
|
||||||
|
{
|
||||||
|
let keep: std::collections::HashSet<String> =
|
||||||
|
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<String> {
|
||||||
|
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup(
|
fn setup(
|
||||||
@@ -71,7 +164,7 @@ fn setup(
|
|||||||
} else {
|
} else {
|
||||||
// home dir
|
// home dir
|
||||||
if let Some(dir) = dirs::data_local_dir() {
|
if let Some(dir) = dirs::data_local_dir() {
|
||||||
dir.join(APP_PREFIX)
|
dir.join(app_dir_name(&reader.exe))
|
||||||
} else {
|
} else {
|
||||||
eprintln!("not found data local dir");
|
eprintln!("not found data local dir");
|
||||||
return None;
|
return None;
|
||||||
@@ -87,10 +180,12 @@ fn setup(
|
|||||||
}
|
}
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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() {
|
for file in reader.files.iter() {
|
||||||
file.write_to_file(&dir);
|
file.write_to_file(&dir);
|
||||||
}
|
}
|
||||||
write_meta(&dir, ts);
|
write_meta(&dir, ts, &metadata_paths);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
win::copy_runtime_broker(&dir);
|
win::copy_runtime_broker(&dir);
|
||||||
#[cfg(linux)]
|
#[cfg(linux)]
|
||||||
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() -> Result<(), String> {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
let mut arg_exe = Default::default();
|
let mut arg_exe = Default::default();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -193,7 +288,7 @@ fn main() {
|
|||||||
let quick_support = false;
|
let quick_support = false;
|
||||||
|
|
||||||
let mut ui = false;
|
let mut ui = false;
|
||||||
let reader = BinaryReader::default();
|
let reader = BinaryReader::new()?;
|
||||||
if let Some(exe) = setup(
|
if let Some(exe) = setup(
|
||||||
reader,
|
reader,
|
||||||
None,
|
None,
|
||||||
@@ -208,6 +303,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
execute(exe, args, ui);
|
execute(exe, args, ui);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -246,3 +342,27 @@ mod win {
|
|||||||
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
417
res/admin-roles.py
Executable file
417
res/admin-roles.py
Executable file
@@ -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()
|
||||||
292
res/control-roles.py
Executable file
292
res/control-roles.py
Executable file
@@ -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()
|
||||||
@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
|
|||||||
|
|
||||||
namespace RemotePrinter
|
namespace RemotePrinter
|
||||||
{
|
{
|
||||||
VOID installUpdatePrinter(const std::wstring& installFolder);
|
// `appName` names the printer and its port. It is passed in rather than compiled
|
||||||
VOID uninstallPrinter();
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
|
|||||||
{
|
{
|
||||||
do
|
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);
|
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
|
||||||
if (process != NULL)
|
if (process != NULL)
|
||||||
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
|
|||||||
DWORD er = ERROR_SUCCESS;
|
DWORD er = ERROR_SUCCESS;
|
||||||
|
|
||||||
int nResult = 0;
|
int nResult = 0;
|
||||||
LPWSTR installFolder = NULL;
|
|
||||||
LPWSTR pwz = NULL;
|
|
||||||
LPWSTR pwzData = NULL;
|
LPWSTR pwzData = NULL;
|
||||||
|
std::wstring appNameValue;
|
||||||
|
std::wstring installFolderValue;
|
||||||
|
|
||||||
hr = WcaInitialize(hInstall, "InstallPrinter");
|
hr = WcaInitialize(hInstall, "InstallPrinter");
|
||||||
ExitOnFailure(hr, "Failed to initialize");
|
ExitOnFailure(hr, "Failed to initialize");
|
||||||
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
|
|||||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||||
|
|
||||||
pwz = pwzData;
|
// "<app name>|<install folder>". Split here rather than through
|
||||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
|
||||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
// 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);
|
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
|
||||||
RemotePrinter::installUpdatePrinter(installFolder);
|
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
|
||||||
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
||||||
|
|
||||||
LExit:
|
LExit:
|
||||||
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
|
|||||||
HRESULT hr = S_OK;
|
HRESULT hr = S_OK;
|
||||||
DWORD er = ERROR_SUCCESS;
|
DWORD er = ERROR_SUCCESS;
|
||||||
|
|
||||||
|
LPWSTR pwzData = NULL;
|
||||||
|
std::wstring appNameValue;
|
||||||
|
|
||||||
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
||||||
ExitOnFailure(hr, "Failed to initialize");
|
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");
|
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
|
||||||
RemotePrinter::uninstallPrinter();
|
RemotePrinter::uninstallPrinter(appNameValue);
|
||||||
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
||||||
|
|
||||||
LExit:
|
LExit:
|
||||||
|
if (pwzData) {
|
||||||
|
ReleaseStr(pwzData);
|
||||||
|
}
|
||||||
|
|
||||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||||
return WcaFinalize(er);
|
return WcaFinalize(er);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ namespace RemotePrinter
|
|||||||
{
|
{
|
||||||
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
|
#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_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_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
|
||||||
|
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
|
||||||
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
|
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<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
|
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
|
||||||
template <typename T, typename R>
|
template <typename T, typename R>
|
||||||
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
|
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
|
||||||
@@ -458,8 +465,12 @@ namespace RemotePrinter
|
|||||||
// We should not check the driver version because the driver is deployed with the application.
|
// 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.
|
// It's better to uninstall the existing driver and install the driver from the application.
|
||||||
// 3. Add the printer.
|
// 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;
|
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
|
||||||
if (!FileExists(infFile))
|
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");
|
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
|
||||||
uninstallDriver(RD_PRINTER_DRIVER_NAME);
|
uninstallDriver(RD_PRINTER_DRIVER_NAME);
|
||||||
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
|
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
|
||||||
checkDeleteLocalPort(RD_PRINTER_PORT);
|
checkDeleteLocalPort(printerName.c_str());
|
||||||
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,14 @@
|
|||||||
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
|
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
|
||||||
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
|
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
|
||||||
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
|
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
|
||||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
|
<!-- The app name comes first and is separated by '|', which cannot occur in a
|
||||||
|
Windows path nor in a validated app name. wcautil's own delimiter is a
|
||||||
|
literal wide char 128 that a Formatted value cannot carry, and [~] is
|
||||||
|
MSI's NUL escape rather than that delimiter, so the action parses this
|
||||||
|
itself. Passing the name keeps the dll free of it, so one build serves
|
||||||
|
every custom client. -->
|
||||||
|
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
|
||||||
|
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
|
||||||
<InstallExecuteSequence>
|
<InstallExecuteSequence>
|
||||||
|
|
||||||
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
||||||
@@ -86,6 +93,7 @@
|
|||||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||||
|
|
||||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||||
|
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT >= 603" />
|
||||||
|
|
||||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
<PropertyRef Id="AddRemovePropertiesFile" />
|
<PropertyRef Id="AddRemovePropertiesFile" />
|
||||||
|
|
||||||
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
||||||
|
<!--$Media2Start$-->
|
||||||
|
<!-- preprocess.py in template mode adds a second cabinet here, holding only
|
||||||
|
the files that differ per customer, so a custom client can be produced by
|
||||||
|
rebuilding that small cabinet instead of the whole package. The shipped
|
||||||
|
msi is built without template mode and keeps a single cabinet. -->
|
||||||
|
<!--$Media2End$-->
|
||||||
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
||||||
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />
|
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import subprocess
|
|||||||
import re
|
import re
|
||||||
import platform
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from itertools import chain
|
|
||||||
import shutil
|
import shutil
|
||||||
from xml.sax.saxutils import quoteattr
|
from xml.sax.saxutils import quoteattr
|
||||||
|
|
||||||
@@ -67,6 +66,14 @@ def make_parser():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--template",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Build a template to be patched per customer rather than a finished "
|
||||||
|
"package: puts the files a custom client replaces in their own cabinet, so "
|
||||||
|
"rebranding rebuilds a few hundred KB instead of the whole payload.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--conn-type",
|
"--conn-type",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -92,6 +99,43 @@ def make_parser():
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# Files a custom client replaces. Kept in their own cabinet by --template so that
|
||||||
|
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
|
||||||
|
# The app executable is handled separately: it has its own component in RustDesk.wxs.
|
||||||
|
#
|
||||||
|
# A template has to ship a placeholder for each of these so there is a File row to
|
||||||
|
# patch, but the branding assets are optional for a customer and a stock build has
|
||||||
|
# none of them at all. So each optional one installs only when its property is set,
|
||||||
|
# which the patcher does for the files a customer actually supplied. Otherwise a
|
||||||
|
# customer without a logo would install the placeholder, where today they get no
|
||||||
|
# logo at all -- the client treats a missing asset as "no logo".
|
||||||
|
PER_CUSTOMER_DISK_ID = 2
|
||||||
|
PER_CUSTOMER_FILES = {
|
||||||
|
# relative path -> property gating installation, or None if always installed
|
||||||
|
"custom.txt": None,
|
||||||
|
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
|
||||||
|
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
|
||||||
|
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
|
||||||
|
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
|
||||||
|
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_relative(relative_path):
|
||||||
|
path = relative_path.replace("\\", "/")
|
||||||
|
while path.startswith("./"):
|
||||||
|
path = path[2:]
|
||||||
|
return path.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_per_customer(relative_path):
|
||||||
|
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
|
||||||
|
|
||||||
|
|
||||||
|
def per_customer_condition(relative_path):
|
||||||
|
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
|
||||||
|
|
||||||
|
|
||||||
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
|
|||||||
return lines, index_start
|
return lines, index_start
|
||||||
|
|
||||||
|
|
||||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
|
||||||
indent = g_indent_unit * 3
|
indent = g_indent_unit * 3
|
||||||
path = Path(dist_dir)
|
path = Path(dist_dir)
|
||||||
idx = 1
|
idx = 1
|
||||||
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
|||||||
if subdir != ".":
|
if subdir != ".":
|
||||||
dir_attr = f'Subdirectory="{subdir}"'
|
dir_attr = f'Subdirectory="{subdir}"'
|
||||||
|
|
||||||
|
relative = file_path.relative_to(path).as_posix()
|
||||||
|
disk_attr = ""
|
||||||
|
condition_attr = ""
|
||||||
|
if template and is_per_customer(relative):
|
||||||
|
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
|
||||||
|
# Branding assets are optional, and the template only carries a
|
||||||
|
# placeholder, so install one only when the customer supplied it.
|
||||||
|
condition = per_customer_condition(relative)
|
||||||
|
if condition:
|
||||||
|
condition_attr = f' Condition="{condition} = 1"'
|
||||||
|
|
||||||
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
||||||
# because it will cause error
|
# because it will cause error
|
||||||
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
||||||
to_insert_lines = f"""
|
to_insert_lines = f"""
|
||||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
|
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
||||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
|
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
||||||
{indent}</Component>
|
{indent}</Component>
|
||||||
"""
|
"""
|
||||||
lines.insert(index_start + 1, to_insert_lines[1:])
|
lines.insert(index_start + 1, to_insert_lines[1:])
|
||||||
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def gen_auto_component(app_name, dist_dir):
|
def gen_auto_component(app_name, dist_dir, template=False):
|
||||||
return gen_content_between_tags(
|
return gen_content_between_tags(
|
||||||
"Package/Components/RustDesk.wxs",
|
"Package/Components/RustDesk.wxs",
|
||||||
"<!--$AutoComonentStart$-->",
|
"<!--$AutoComonentStart$-->",
|
||||||
"<!--$AutoComponentEnd$-->",
|
"<!--$AutoComponentEnd$-->",
|
||||||
lambda lines, index_start: insert_components_between_tags(
|
lambda lines, index_start: insert_components_between_tags(
|
||||||
lines, index_start, app_name, dist_dir
|
lines, index_start, app_name, dist_dir, template
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gen_media2():
|
||||||
|
"""Second cabinet holding only what a custom client replaces."""
|
||||||
|
|
||||||
|
def func(lines, index_start):
|
||||||
|
indent = g_indent_unit * 2
|
||||||
|
lines.insert(
|
||||||
|
index_start + 1,
|
||||||
|
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
|
||||||
|
' EmbedCab="yes" CompressionLevel="high" />\n',
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
return gen_content_between_tags(
|
||||||
|
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def put_app_exe_on_media2():
|
||||||
|
"""The app executable has its own component, so it is moved by name."""
|
||||||
|
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
|
||||||
|
with open(target, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
|
||||||
|
new = (
|
||||||
|
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
|
||||||
|
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
|
||||||
|
)
|
||||||
|
if content.count(old) != 1:
|
||||||
|
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
|
||||||
|
return False
|
||||||
|
with open(target, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content.replace(old, new))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def gen_pre_vars(args, dist_dir):
|
def gen_pre_vars(args, dist_dir):
|
||||||
def func(lines, index_start):
|
def func(lines, index_start):
|
||||||
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
||||||
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
|
|||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.writelines(lines)
|
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 gen_upgrade_info():
|
||||||
def func(lines, index_start):
|
def func(lines, index_start):
|
||||||
indent = g_indent_unit * 3
|
indent = g_indent_unit * 3
|
||||||
@@ -478,11 +556,16 @@ if __name__ == "__main__":
|
|||||||
if not gen_conn_type(args):
|
if not gen_conn_type(args):
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
if not gen_auto_component(app_name, dist_dir):
|
if args.template:
|
||||||
|
if not gen_media2():
|
||||||
|
sys.exit(-1)
|
||||||
|
if not put_app_exe_on_media2():
|
||||||
|
sys.exit(-1)
|
||||||
|
|
||||||
|
if not gen_auto_component(app_name, dist_dir, args.template):
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
if not gen_custom_dialog_bitmaps():
|
if not gen_custom_dialog_bitmaps():
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
replace_app_name_in_langs(args.app_name)
|
replace_app_name_in_langs(args.app_name)
|
||||||
replace_app_name_in_custom_actions(args.app_name)
|
|
||||||
|
|||||||
@@ -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-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."),
|
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
|
||||||
("Save as", "Opslaan als"),
|
("Save as", "Opslaan als"),
|
||||||
("Export", ""),
|
("Export", "Exporteren"),
|
||||||
("Export Logs", ""),
|
("Export Logs", "Logboeken exporteren"),
|
||||||
("Import Folder", ""),
|
("Import Folder", "Map importeren"),
|
||||||
("Copy to clipboard", "Kopiëren naar het klembord"),
|
("Copy to clipboard", "Kopiëren naar het klembord"),
|
||||||
("Enable remote printer", "Printer op afstand inschakelen"),
|
("Enable remote printer", "Printer op afstand inschakelen"),
|
||||||
("Downloading {}", "Downloaden {}"),
|
("Downloading {}", "Downloaden {}"),
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Enter your password", "輸入您的密碼"),
|
("Enter your password", "輸入您的密碼"),
|
||||||
("Logging in...", "正在登入..."),
|
("Logging in...", "正在登入..."),
|
||||||
("Enable RDP session sharing", "啟用 RDP 工作階段分享"),
|
("Enable RDP session sharing", "啟用 RDP 工作階段分享"),
|
||||||
("Auto Login", "自動登入 (只在您設定「工作階段結束後鎖定」時有效)"),
|
("Auto Login", "自動登入(只在您設定「工作階段結束後鎖定」時有效)"),
|
||||||
("Enable direct IP access", "啟用 IP 直接存取"),
|
("Enable direct IP access", "啟用 IP 直接存取"),
|
||||||
("Rename", "重新命名"),
|
("Rename", "重新命名"),
|
||||||
("Space", "空白"),
|
("Space", "空白"),
|
||||||
@@ -300,7 +300,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Language", "語言"),
|
("Language", "語言"),
|
||||||
("Keep RustDesk background service", "保持 RustDesk 後台服務"),
|
("Keep RustDesk background service", "保持 RustDesk 後台服務"),
|
||||||
("Ignore Battery Optimizations", "忽略電池最佳化"),
|
("Ignore Battery Optimizations", "忽略電池最佳化"),
|
||||||
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
|
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」。"),
|
||||||
("Start on boot", "開機時啟動"),
|
("Start on boot", "開機時啟動"),
|
||||||
("Start the screen sharing service on boot, requires special permissions", "開機時啟動螢幕分享服務,需要特殊權限。"),
|
("Start the screen sharing service on boot, requires special permissions", "開機時啟動螢幕分享服務,需要特殊權限。"),
|
||||||
("Connection not allowed", "不允許連線"),
|
("Connection not allowed", "不允許連線"),
|
||||||
@@ -519,11 +519,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("I Agree", "同意"),
|
("I Agree", "同意"),
|
||||||
("Decline", "拒絕"),
|
("Decline", "拒絕"),
|
||||||
("Timeout in minutes", "超時(分鐘)"),
|
("Timeout in minutes", "超時(分鐘)"),
|
||||||
("auto_disconnect_option_tip", "自動在連入的使用者不活躍時關閉工作階段"),
|
("auto_disconnect_option_tip", "自動關閉不活躍的連入工作階段"),
|
||||||
("Connection failed due to inactivity", "由於長時間沒有操作,已自動關閉工作階段"),
|
("Connection failed due to inactivity", "由於長時間沒有操作,已自動關閉工作階段"),
|
||||||
("Check for software update on startup", "啟動時檢查更新"),
|
("Check for software update on startup", "啟動時檢查更新"),
|
||||||
("upgrade_rustdesk_server_pro_to_{}_tip", "請升級專業版伺服器到{}或更高版本!"),
|
("upgrade_rustdesk_server_pro_to_{}_tip", "請升級專業版伺服器到{}或更高版本!"),
|
||||||
("pull_group_failed_tip", "獲取群組訊息失敗"),
|
("pull_group_failed_tip", "重新整理群組失敗"),
|
||||||
("Filter by intersection", "按照交集篩選"),
|
("Filter by intersection", "按照交集篩選"),
|
||||||
("Remove wallpaper during incoming sessions", "在接受連入連線時移除桌布"),
|
("Remove wallpaper during incoming sessions", "在接受連入連線時移除桌布"),
|
||||||
("Test", "測試"),
|
("Test", "測試"),
|
||||||
@@ -639,7 +639,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Use D3D rendering", "使用 D3D 渲染"),
|
("Use D3D rendering", "使用 D3D 渲染"),
|
||||||
("Printer", "印表機"),
|
("Printer", "印表機"),
|
||||||
("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"),
|
("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"),
|
||||||
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此設備。"),
|
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此裝置。"),
|
||||||
("printer-{}-not-installed-tip", "{} 印表機未安裝。"),
|
("printer-{}-not-installed-tip", "{} 印表機未安裝。"),
|
||||||
("printer-{}-ready-tip", "{} 印表機已安裝,您可以使用列印功能了。"),
|
("printer-{}-ready-tip", "{} 印表機已安裝,您可以使用列印功能了。"),
|
||||||
("Install {} Printer", "安裝 {} 印表機"),
|
("Install {} Printer", "安裝 {} 印表機"),
|
||||||
@@ -659,17 +659,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"),
|
("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"),
|
||||||
("screenshot-action-tip", "請選擇要如何處理這張截圖。"),
|
("screenshot-action-tip", "請選擇要如何處理這張截圖。"),
|
||||||
("Save as", "另存為"),
|
("Save as", "另存為"),
|
||||||
("Export", ""),
|
("Export", "匯出"),
|
||||||
("Export Logs", ""),
|
("Export Logs", "匯出日誌"),
|
||||||
("Import Folder", ""),
|
("Import Folder", "匯入資料夾"),
|
||||||
("Copy to clipboard", "複製到剪貼簿"),
|
("Copy to clipboard", "複製到剪貼簿"),
|
||||||
("Enable remote printer", "啟用遠端列印"),
|
("Enable remote printer", "啟用遠端列印"),
|
||||||
("Downloading {}", "正在下載 {} 並安裝新版本。"),
|
("Downloading {}", "正在下載 {} 並安裝新版本。"),
|
||||||
("{} Update", "{} 更新"),
|
("{} Update", "{} 更新"),
|
||||||
("{}-to-update-tip", "即將關閉 {} 並安裝新版本。"),
|
("{}-to-update-tip", "即將關閉 {} 並安裝新版本。"),
|
||||||
("download-new-version-failed-tip", "下載失敗,您可以重試或點擊\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
("download-new-version-failed-tip", "下載失敗,您可以重試或點選\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||||
("Auto update", "自動更新"),
|
("Auto update", "自動更新"),
|
||||||
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點擊\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點選\"下載\"按鈕以從發布網址下載,並手動升級。"),
|
||||||
("websocket_tip", "使用 WebSocket 時,只支援使用中繼連接。"),
|
("websocket_tip", "使用 WebSocket 時,只支援使用中繼連接。"),
|
||||||
("Use WebSocket", "使用 WebSocket"),
|
("Use WebSocket", "使用 WebSocket"),
|
||||||
("Trackpad speed", "觸控板速度"),
|
("Trackpad speed", "觸控板速度"),
|
||||||
@@ -680,7 +680,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("View camera", "檢視相機"),
|
("View camera", "檢視相機"),
|
||||||
("Enable camera", "允許查看鏡頭"),
|
("Enable camera", "允許查看鏡頭"),
|
||||||
("No cameras", "沒有鏡頭"),
|
("No cameras", "沒有鏡頭"),
|
||||||
("view_camera_unsupported_tip", "您的遠端設備不支援查看鏡頭"),
|
("view_camera_unsupported_tip", "您的遠端裝置不支援查看鏡頭"),
|
||||||
("Terminal", "終端機"),
|
("Terminal", "終端機"),
|
||||||
("Enable terminal", "啟用終端機"),
|
("Enable terminal", "啟用終端機"),
|
||||||
("New tab", "新分頁"),
|
("New tab", "新分頁"),
|
||||||
@@ -690,7 +690,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Failed to get user token.", "取得使用者權杖失敗"),
|
("Failed to get user token.", "取得使用者權杖失敗"),
|
||||||
("Incorrect username or password.", "使用者名稱或密碼不正確"),
|
("Incorrect username or password.", "使用者名稱或密碼不正確"),
|
||||||
("The user is not an administrator.", "使用者並不是系統管理員"),
|
("The user is not an administrator.", "使用者並不是系統管理員"),
|
||||||
("Failed to check if the user is an administrator.", "檢查使用者是否是系統管理員時失敗了"),
|
("Failed to check if the user is an administrator.", "無法確認使用者是否為系統管理員"),
|
||||||
("Supported only in the installed version.", "僅支援於已安裝的版本"),
|
("Supported only in the installed version.", "僅支援於已安裝的版本"),
|
||||||
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
|
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
|
||||||
("Preparing for installation ...", "正在準備安裝..."),
|
("Preparing for installation ...", "正在準備安裝..."),
|
||||||
@@ -747,16 +747,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("Show monitor switch button on the main toolbar", "在主工具列上顯示螢幕切換按鈕"),
|
("Show monitor switch button on the main toolbar", "在主工具列上顯示螢幕切換按鈕"),
|
||||||
("Show on the minimized toolbar", "在最小化工具列上顯示"),
|
("Show on the minimized toolbar", "在最小化工具列上顯示"),
|
||||||
("All monitors", "所有顯示器"),
|
("All monitors", "所有顯示器"),
|
||||||
("#{} monitor", "{}號顯示器"),
|
("#{} monitor", "{} 號顯示器"),
|
||||||
("conn-e2ee-unavailable-tip", "無法驗證端對端加密。\n遠端裝置可能仍在準備中,請稍後重試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
|
("conn-e2ee-unavailable-tip", "無法驗證端到端加密。\n遠端裝置可能仍在準備中,請稍後再試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
|
||||||
("ID whitelisting", "ID 白名單"),
|
("ID whitelisting", "ID 白名單"),
|
||||||
("Use ID whitelisting", "只允許白名單上的 ID 進行連線"),
|
("Use ID whitelisting", "只允許白名單上的 ID 進行連線"),
|
||||||
("id_whitelist_tip", "只有白名單上的 ID 可以存取"),
|
("id_whitelist_tip", "只有白名單上的 ID 可以存取"),
|
||||||
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
|
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 以符合任意數量的字元,'?' 以符合單一字元"),
|
||||||
("Invalid ID", "ID 無效"),
|
("Invalid ID", "ID 無效"),
|
||||||
("Your ID is blocked by the peer", "你的 ID 已被對方封鎖"),
|
("Your ID is blocked by the peer", "您的 ID 已被對方封鎖"),
|
||||||
("Your ip is blocked by the peer", "你的 IP 已被對方封鎖"),
|
("Your ip is blocked by the peer", "您的 IP 已被對方封鎖"),
|
||||||
("id_whitelist_caveat_tip", "ID 由對端用戶端回報,白名單用於減少暴露面,不能取代密碼或 2FA"),
|
("id_whitelist_caveat_tip", "ID 由對端客戶端回報。此白名單用於減少暴露面,不能取代密碼或 2FA。"),
|
||||||
("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"),
|
("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"),
|
||||||
("Continue", "繼續"),
|
("Continue", "繼續"),
|
||||||
("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"),
|
("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"),
|
||||||
|
|||||||
Reference in New Issue
Block a user