mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-15 00:41:01 +03:00
Compare commits
12 Commits
hdr-tonema
...
ba65c30df1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba65c30df1 | ||
|
|
ac0b226568 | ||
|
|
0d8a52bc5a | ||
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 | ||
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd |
78
.github/workflows/flutter-build.yml
vendored
78
.github/workflows/flutter-build.yml
vendored
@@ -43,6 +43,7 @@ env:
|
|||||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||||
|
VCPKG_CMAKE_VERSION: "4.3.0"
|
||||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||||
VERSION: "1.5.0"
|
VERSION: "1.5.0"
|
||||||
NDK_VERSION: "r28c"
|
NDK_VERSION: "r28c"
|
||||||
@@ -389,6 +390,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 +974,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
|
||||||
@@ -1505,6 +1572,15 @@ jobs:
|
|||||||
name: bridge-artifact
|
name: bridge-artifact
|
||||||
path: ./
|
path: ./
|
||||||
|
|
||||||
|
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
|
||||||
|
- name: Install CMake for vcpkg on Linux ARM64
|
||||||
|
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
|
||||||
|
user_base="$(python3 -m site --user-base)"
|
||||||
|
"${user_base}/bin/cmake" --version
|
||||||
|
echo "${user_base}/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
- name: Setup vcpkg with Github Actions binary cache
|
- name: Setup vcpkg with Github Actions binary cache
|
||||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||||
|
|||||||
Submodule libs/hbb_common updated: b2b1ac453d...05ed68fed8
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,7 +132,15 @@ impl Display {
|
|||||||
.map(Display)
|
.map(Display)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let displays_dxgi = Self::all_().unwrap_or(Default::default());
|
let mut displays_dxgi = match Self::all_() {
|
||||||
|
Ok(displays) => displays,
|
||||||
|
Err(e) => {
|
||||||
|
hbb_common::log::error!("DXGI display enumeration failed: {e}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
|
||||||
|
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
|
||||||
|
|
||||||
// Return gdi displays if dxgi is not supported
|
// Return gdi displays if dxgi is not supported
|
||||||
if displays_dxgi.is_empty() {
|
if displays_dxgi.is_empty() {
|
||||||
@@ -155,7 +163,6 @@ impl Display {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reorder displays from dxgi
|
// Reorder displays from dxgi
|
||||||
let mut displays_dxgi = displays_dxgi;
|
|
||||||
let mut displays_dxgi_ordered = Vec::new();
|
let mut displays_dxgi_ordered = Vec::new();
|
||||||
for name in names_gdi.iter() {
|
for name in names_gdi.iter() {
|
||||||
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
||||||
@@ -176,11 +183,11 @@ impl Display {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn width(&self) -> usize {
|
pub fn width(&self) -> usize {
|
||||||
self.0.width() as usize
|
self.0.width().max(0) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn height(&self) -> usize {
|
pub fn height(&self) -> usize {
|
||||||
self.0.height() as usize
|
self.0.height().max(0) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> String {
|
||||||
@@ -201,7 +208,8 @@ impl Display {
|
|||||||
|
|
||||||
pub fn is_primary(&self) -> bool {
|
pub fn is_primary(&self) -> bool {
|
||||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
||||||
self.origin() == (0, 0)
|
// Detached outputs can still report origin (0,0) with a zero size.
|
||||||
|
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "vram")]
|
#[cfg(feature = "vram")]
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
|
|||||||
// capturer rebuild loop clears about once a second.
|
// capturer rebuild loop clears about once a second.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
|
||||||
|
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
|
||||||
|
// every OTHER live capturer on a multi-display session. A capturer records this at build and
|
||||||
|
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
|
||||||
|
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
|
||||||
|
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
|
||||||
|
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
pub fn wayland_snapshot_missing() -> bool {
|
||||||
|
DISPLAYS.lock().unwrap().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "drm"))]
|
||||||
|
pub fn bump_layout_generation() {
|
||||||
|
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
pub fn wayland_snapshot_generation() -> u64 {
|
||||||
|
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
// Return (min_x, max_x, min_y, max_y)
|
// Return (min_x, max_x, min_y, max_y)
|
||||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||||
let wayland_displays = get_displays();
|
let wayland_displays = get_displays();
|
||||||
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
|||||||
// Otherwise, we use the logical size for `uinput`.
|
// Otherwise, we use the logical size for `uinput`.
|
||||||
if displays.len() == 1 {
|
if displays.len() == 1 {
|
||||||
let d = &displays[0];
|
let d = &displays[0];
|
||||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
let (w, h) = oriented_physical(d);
|
||||||
|
return Some((d.x, d.x + w, d.y, d.y + h));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut min_x = i32::MAX;
|
let mut min_x = i32::MAX;
|
||||||
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
|||||||
min_y = min_y.min(d.y);
|
min_y = min_y.min(d.y);
|
||||||
let size = if let Some(logical_size) = d.logical_size {
|
let size = if let Some(logical_size) = d.logical_size {
|
||||||
logical_size
|
logical_size
|
||||||
|
} else if d.transform == 90 || d.transform == 270 {
|
||||||
|
oriented_physical(d)
|
||||||
} else {
|
} else {
|
||||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||||
// This may occur if the Wayland compositor does not provide logical size information,
|
// This may occur if the Wayland compositor does not provide logical size information,
|
||||||
@@ -374,6 +401,24 @@ pub struct DisplayRect {
|
|||||||
pub y: i32,
|
pub y: i32,
|
||||||
pub w: i32,
|
pub w: i32,
|
||||||
pub h: i32,
|
pub h: i32,
|
||||||
|
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
|
||||||
|
// otherwise identical; the remap itself matches by name and containment, never by this.
|
||||||
|
pub transform: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
|
||||||
|
/// advertised and pointed at as HxW.
|
||||||
|
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
|
||||||
|
if d.transform == 90 || d.transform == 270 {
|
||||||
|
(d.height, d.width)
|
||||||
|
} else {
|
||||||
|
(d.width, d.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The logical rectangles of a display list, for a caller that already has the list.
|
||||||
|
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||||
|
logical_rects_of(displays)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let (w, h) = if single {
|
let (w, h) = if single {
|
||||||
(d.width, d.height)
|
oriented_physical(d)
|
||||||
} else {
|
} else {
|
||||||
d.logical_size.unwrap_or((d.width, d.height))
|
d.logical_size.unwrap_or_else(|| oriented_physical(d))
|
||||||
};
|
};
|
||||||
DisplayRect {
|
DisplayRect {
|
||||||
name: d.name.clone(),
|
name: d.name.clone(),
|
||||||
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
|||||||
y: d.y,
|
y: d.y,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
|
transform: d.transform,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -495,8 +541,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_clear_keeps_the_failure_stamp() {
|
fn test_clear_keeps_the_failure_stamp() {
|
||||||
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
||||||
// and dropping the stamp with it would defeat the backoff. Sole test touching these
|
// and dropping the stamp with it would defeat the backoff. The generation test also
|
||||||
// statics; serialize before adding another.
|
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
|
||||||
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
||||||
clear_wayland_displays_cache();
|
clear_wayland_displays_cache();
|
||||||
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
||||||
@@ -519,6 +565,7 @@ mod tests {
|
|||||||
height,
|
height,
|
||||||
logical_size,
|
logical_size,
|
||||||
refresh_rate: 60,
|
refresh_rate: 60,
|
||||||
|
transform: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +600,42 @@ mod tests {
|
|||||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_rotated_display_swaps_the_uinput_rect() {
|
||||||
|
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
|
||||||
|
// mode, so the pointer could not reach ~44% of a portrait screen.
|
||||||
|
let mut d = display(0, 0, 1920, 1080, None);
|
||||||
|
d.transform = 90;
|
||||||
|
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
|
||||||
|
let rects = logical_rects_of(&[d]);
|
||||||
|
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_transform_flip_is_visible_to_the_drift_comparison() {
|
||||||
|
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
|
||||||
|
// field is what lets `baseline != live` fire on them.
|
||||||
|
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
|
||||||
|
let mut b = a.clone();
|
||||||
|
a.transform = 90;
|
||||||
|
b.transform = 270;
|
||||||
|
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_explicit_bump_moves_the_generation() {
|
||||||
|
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
|
||||||
|
// every other live capturer (adversarial finding on the first version of this).
|
||||||
|
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
|
||||||
|
clear_wayland_displays_cache();
|
||||||
|
assert_eq!(
|
||||||
|
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
bump_layout_generation();
|
||||||
|
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
|
||||||
|
}
|
||||||
|
|
||||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||||
DisplayRect {
|
DisplayRect {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
@@ -560,6 +643,7 @@ mod tests {
|
|||||||
y,
|
y,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
|
transform: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
|
("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 {}", "正在下载 {}"),
|
||||||
|
|||||||
@@ -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 {}"),
|
||||||
|
|||||||
@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
|||||||
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
|
||||||
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
|
||||||
("Save as", "Salvar como"),
|
("Save as", "Salvar como"),
|
||||||
("Export", ""),
|
("Export", "Exportar"),
|
||||||
("Export Logs", ""),
|
("Export Logs", "Exportar logs"),
|
||||||
("Import Folder", ""),
|
("Import Folder", "Importar pasta"),
|
||||||
("Copy to clipboard", "Copiar para área de transferência"),
|
("Copy to clipboard", "Copiar para área de transferência"),
|
||||||
("Enable remote printer", "Habilitar impressora remota"),
|
("Enable remote printer", "Habilitar impressora remota"),
|
||||||
("Downloading {}", "Baixando {}"),
|
("Downloading {}", "Baixando {}"),
|
||||||
|
|||||||
@@ -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.", "瀏覽器未開啟?請使用下方網址登入。"),
|
||||||
|
|||||||
@@ -585,13 +585,9 @@ impl Connection {
|
|||||||
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
|
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
|
||||||
let mut last_recv_time = Instant::now();
|
let mut last_recv_time = Instant::now();
|
||||||
|
|
||||||
conn.stream.set_send_timeout(
|
// The connection type is not known until the login request arrives;
|
||||||
if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() || conn.terminal {
|
// `on_message` picks the type-specific timeout then.
|
||||||
SEND_TIMEOUT_OTHER
|
conn.stream.set_send_timeout(SEND_TIMEOUT_VIDEO);
|
||||||
} else {
|
|
||||||
SEND_TIMEOUT_VIDEO
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned));
|
std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned));
|
||||||
@@ -2766,6 +2762,17 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.stream.set_send_timeout(
|
||||||
|
if self.file_transfer.is_some()
|
||||||
|
|| self.terminal
|
||||||
|
|| matches!(self.lr.union, Some(login_request::Union::PortForward(_)))
|
||||||
|
{
|
||||||
|
SEND_TIMEOUT_OTHER
|
||||||
|
} else {
|
||||||
|
SEND_TIMEOUT_VIDEO
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if !crate::common::is_direct_ip_access(&lr.username) && lr.username != Config::get_id()
|
if !crate::common::is_direct_ip_access(&lr.username) && lr.username != Config::get_id()
|
||||||
{
|
{
|
||||||
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
||||||
|
|||||||
@@ -53,6 +53,82 @@ struct WaylandUinputRect {
|
|||||||
struct WaylandLayout {
|
struct WaylandLayout {
|
||||||
baseline: Vec<scrap::wayland::display::DisplayRect>,
|
baseline: Vec<scrap::wayland::display::DisplayRect>,
|
||||||
live: Vec<scrap::wayland::display::DisplayRect>,
|
live: Vec<scrap::wayland::display::DisplayRect>,
|
||||||
|
// What the live capturers were built against. Separate from `baseline` because a session
|
||||||
|
// init resets that one, and the generation detector needs a memory that a reset cannot
|
||||||
|
// erase: two inits straddling a rotation would otherwise leave nothing to compare against.
|
||||||
|
seen: Vec<scrap::wayland::display::DisplayRect>,
|
||||||
|
// A capturer recorded a build layout other than `seen`, tagged with the generation it was
|
||||||
|
// built at: the poll observed the live layout between that capturer's snapshot read and its
|
||||||
|
// record, so one of the two is stale and the next poll owes an edge whatever it sees. Only
|
||||||
|
// while that generation is current: the record can also land between the poll consuming an
|
||||||
|
// edge and the bump it promotes (or after the bump, with a snapshot from before it), and that
|
||||||
|
// capturer rebuilds on its own, so a second promotion would tear the fresh ones down again.
|
||||||
|
// Consumed by `observe`, which the poll runs right after `edge`; a session init's baseline
|
||||||
|
// reset leaves it alone.
|
||||||
|
unseen_build: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
impl WaylandLayout {
|
||||||
|
// Replace the per-session input baseline. Before the first poll the outgoing baseline is
|
||||||
|
// the only record of the layout the capturers were built against, so it seeds `seen`.
|
||||||
|
fn reset_baseline(&mut self, baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||||
|
if self.seen.is_empty() {
|
||||||
|
let previous = std::mem::take(&mut self.baseline);
|
||||||
|
self.seen = previous;
|
||||||
|
}
|
||||||
|
self.baseline = baseline;
|
||||||
|
self.live.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EDGE (live vs the layout the capturers were built against), not a level: comparing
|
||||||
|
// against the baseline latches true for the whole session. With nothing observed yet the
|
||||||
|
// baseline is that record, and a missing snapshot at init makes the first success the edge,
|
||||||
|
// or transform=0 sticks.
|
||||||
|
fn edge(
|
||||||
|
&self,
|
||||||
|
live: &[scrap::wayland::display::DisplayRect],
|
||||||
|
snapshot_missing: bool,
|
||||||
|
generation: u64,
|
||||||
|
) -> bool {
|
||||||
|
if self.unseen_build == Some(generation) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !self.seen.is_empty() {
|
||||||
|
return self.seen != live;
|
||||||
|
}
|
||||||
|
if self.baseline.is_empty() {
|
||||||
|
return snapshot_missing;
|
||||||
|
}
|
||||||
|
self.baseline != live
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(&mut self, live: &[scrap::wayland::display::DisplayRect]) {
|
||||||
|
self.live = live.to_vec();
|
||||||
|
self.seen = live.to_vec();
|
||||||
|
self.unseen_build = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a capturer was built against, which seeds the memory when nothing else has. A session
|
||||||
|
// init whose wayland query failed leaves an EMPTY baseline, and the capturer's own retry can
|
||||||
|
// then succeed - so the capturer is the only thing that knows the layout it is showing, and
|
||||||
|
// without this a rotation before the first poll is invisible to `edge`. Only when empty: a
|
||||||
|
// capturer built later must not overwrite the memory the poll is keeping, since on a
|
||||||
|
// multi-display session that memory is what the OTHER capturers were built against. A build
|
||||||
|
// that disagrees with it is flagged instead: the capturer's snapshot read and this record
|
||||||
|
// are two steps, and a poll landing between them observes the live layout first, which
|
||||||
|
// would otherwise drop the record and leave the capturer on a transform nothing compares.
|
||||||
|
fn note_capturer(&mut self, built_on: &[scrap::wayland::display::DisplayRect], built_gen: u64) {
|
||||||
|
if built_on.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.seen.is_empty() {
|
||||||
|
self.seen = built_on.to_vec();
|
||||||
|
} else if self.seen != built_on {
|
||||||
|
// The newest generation wins: a stale record landing late must not hide a fresh one.
|
||||||
|
self.unseen_build = Some(self.unseen_build.map_or(built_gen, |g| g.max(built_gen)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
|
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
|
||||||
@@ -75,9 +151,24 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||||
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
|
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
|
||||||
let mut lock = WAYLAND_LAYOUT.lock().unwrap();
|
WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline);
|
||||||
lock.baseline = baseline;
|
}
|
||||||
lock.live.clear();
|
|
||||||
|
/// Record the layout a capturer was just built against, and the snapshot generation it read
|
||||||
|
/// before taking that layout. See `WaylandLayout::note_capturer`.
|
||||||
|
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||||
|
pub(super) fn note_capturer_layout(
|
||||||
|
displays: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||||
|
built_gen: u64,
|
||||||
|
) {
|
||||||
|
if displays.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rects = scrap::wayland::display::logical_rects_of_displays(displays);
|
||||||
|
WAYLAND_LAYOUT
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.note_capturer(&rects, built_gen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap an injected coordinate onto the live compositor layout when it has drifted from
|
// Remap an injected coordinate onto the live compositor layout when it has drifted from
|
||||||
@@ -100,11 +191,6 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
|||||||
if is_x11() || !crate::input_service::wayland_use_uinput() {
|
if is_x11() || !crate::input_service::wayland_use_uinput() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Nothing to poll at a login screen; the DRM path owns the rect there.
|
|
||||||
#[cfg(feature = "drm")]
|
|
||||||
if crate::platform::linux::is_login_screen_wayland_cached() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
|
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
|
||||||
if let Some(last_check) = lock.last_check {
|
if let Some(last_check) = lock.last_check {
|
||||||
@@ -120,14 +206,55 @@ fn refresh_wayland_uinput_rect_if_changed() {
|
|||||||
// Refresh the per-display layout every poll: monitor origins can shift (e.g. two
|
// Refresh the per-display layout every poll: monitor origins can shift (e.g. two
|
||||||
// displays swap positions) without changing the overall desktop rect, and the mouse
|
// displays swap positions) without changing the overall desktop rect, and the mouse
|
||||||
// path needs the current per-display geometry to correct coordinates.
|
// path needs the current per-display geometry to correct coordinates.
|
||||||
let drifted = {
|
let (live_changed, mut drifted) = {
|
||||||
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
|
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
let snapshot_missing = scrap::wayland::display::wayland_snapshot_missing();
|
||||||
|
#[cfg(not(feature = "drm"))]
|
||||||
|
let snapshot_missing = false;
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
let generation = scrap::wayland::display::wayland_snapshot_generation();
|
||||||
|
#[cfg(not(feature = "drm"))]
|
||||||
|
let generation = 0;
|
||||||
|
let live_changed = layout.edge(&live_rects, snapshot_missing, generation);
|
||||||
let drifted = !layout.baseline.is_empty()
|
let drifted = !layout.baseline.is_empty()
|
||||||
&& !live_rects.is_empty()
|
&& !live_rects.is_empty()
|
||||||
&& layout.baseline != live_rects;
|
&& layout.baseline != live_rects;
|
||||||
layout.live = live_rects;
|
layout.observe(&live_rects);
|
||||||
drifted
|
(live_changed, drifted)
|
||||||
};
|
};
|
||||||
|
// Single owner of the generation bump: on the cache clear it let every session init tear
|
||||||
|
// down every other live capturer. Baseline promotes with the clear (rustdesk#15601).
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
{
|
||||||
|
// An edge seen while DRM is transiently non-Available stays OWED rather than consumed.
|
||||||
|
static PROMOTION_OWED: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
// The latch fires when a capturer was built with no wayland snapshot: a later cache
|
||||||
|
// refill makes wayland_snapshot_missing lie, so live_changed alone would miss it. Taken
|
||||||
|
// UNCONDITIONALLY: short-circuiting past it on a live_changed poll would leave it set and
|
||||||
|
// spend a second, spurious promotion one poll later on the freshly rebuilt capturer.
|
||||||
|
let blind_build = super::drm_capturer::take_unrotated_snapshot_pending();
|
||||||
|
if live_changed || blind_build {
|
||||||
|
PROMOTION_OWED.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
if PROMOTION_OWED.load(Ordering::Acquire) && super::drm_capturer::is_available_cached() {
|
||||||
|
PROMOTION_OWED.store(false, Ordering::Release);
|
||||||
|
scrap::wayland::display::clear_wayland_displays_cache();
|
||||||
|
scrap::wayland::display::bump_layout_generation();
|
||||||
|
set_wayland_layout_baseline(live_rects.clone());
|
||||||
|
WAYLAND_LAYOUT.lock().unwrap().live = live_rects.clone();
|
||||||
|
drifted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "drm"))]
|
||||||
|
let _ = live_changed;
|
||||||
|
// At a login screen the DRM path owns the rect; only the range/remap update is skipped,
|
||||||
|
// the snapshot invalidation above must still run (a greeter session has no other trigger).
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
if crate::platform::linux::is_login_screen_wayland_cached() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The remap corrects for per-display origin shifts; the uinput ABS range corrects for
|
// The remap corrects for per-display origin shifts; the uinput ABS range corrects for
|
||||||
// the overall bounding box. Only enable the remap once the range matches the live
|
// the overall bounding box. Only enable the remap once the range matches the live
|
||||||
// layout, otherwise moves would be remapped into a range the device is not yet using.
|
// layout, otherwise moves would be remapped into a range the device is not yet using.
|
||||||
@@ -721,3 +848,177 @@ mod tests {
|
|||||||
assert_eq!(normalize_primary_display_idx(2, 2), 0);
|
assert_eq!(normalize_primary_display_idx(2, 2), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod wayland_layout_tests {
|
||||||
|
use super::WaylandLayout;
|
||||||
|
use scrap::wayland::display::DisplayRect;
|
||||||
|
|
||||||
|
fn layout(w: i32, h: i32, transform: i32) -> Vec<DisplayRect> {
|
||||||
|
vec![DisplayRect {
|
||||||
|
name: "DP-1".into(),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// rustdesk#15886: a video service starts, the output rotates, and a retry starts before the
|
||||||
|
// 1.5 s poll. The baseline is reset on both, so it cannot be the edge detector's memory.
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_between_two_session_inits_is_still_an_edge() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.observe(&upright);
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.reset_baseline(rotated.clone());
|
||||||
|
assert!(l.edge(&rotated, false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same, with no poll ever having run: the outgoing baseline is the only record of what
|
||||||
|
// the first capturer was built against.
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.reset_baseline(rotated.clone());
|
||||||
|
assert!(l.edge(&rotated, false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: without it the asserts above would pass on a detector that always fires.
|
||||||
|
#[test]
|
||||||
|
fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.observe(&upright);
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
assert!(!l.edge(&upright, false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// rustdesk#15886: `ensure_inited()` runs the wayland query BEFORE the capturer exists, and a
|
||||||
|
// failure there saves an EMPTY baseline. The capturer's own retry can succeed a moment later
|
||||||
|
// and build on layout A, and that build is not blind, so nothing else records it. A rotation
|
||||||
|
// before the first poll then had no memory to be an edge against.
|
||||||
|
#[test]
|
||||||
|
fn a_capturer_built_after_a_failed_init_still_owes_a_rebuild() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(Vec::new());
|
||||||
|
l.note_capturer(&upright, 0);
|
||||||
|
assert!(l.edge(&rotated, false, 0));
|
||||||
|
|
||||||
|
// The same with another baseline reset between the build and the poll.
|
||||||
|
let mut l2 = WaylandLayout::default();
|
||||||
|
l2.reset_baseline(Vec::new());
|
||||||
|
l2.note_capturer(&upright, 0);
|
||||||
|
l2.reset_baseline(rotated.clone());
|
||||||
|
assert!(l2.edge(&rotated, false, 0));
|
||||||
|
|
||||||
|
// Control: no rotation, no edge, in both shapes.
|
||||||
|
let mut l3 = WaylandLayout::default();
|
||||||
|
l3.reset_baseline(Vec::new());
|
||||||
|
l3.note_capturer(&upright, 0);
|
||||||
|
assert!(!l3.edge(&upright, false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A capturer built while the poll already has a memory must not overwrite it.
|
||||||
|
#[test]
|
||||||
|
fn a_later_capturer_does_not_overwrite_the_polls_memory() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.observe(&upright);
|
||||||
|
l.note_capturer(&rotated, 0);
|
||||||
|
assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The constructor's snapshot read and its `note_capturer` are two steps, and the poll can
|
||||||
|
// land between them. After a failed init (empty baseline) the constructor takes A and
|
||||||
|
// publishes it; the output rotates; the poll reads B live, finds nothing recorded and the
|
||||||
|
// snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a
|
||||||
|
// non-empty memory and was dropped: the capturer showed A while the detector held B, and B
|
||||||
|
// against B never bumped the generation.
|
||||||
|
#[test]
|
||||||
|
fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(Vec::new());
|
||||||
|
assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present");
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.note_capturer(&upright, 0);
|
||||||
|
assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated");
|
||||||
|
|
||||||
|
// The promotion consumes it: the next poll sees the same layout and stays quiet.
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.reset_baseline(rotated.clone());
|
||||||
|
assert!(!l.edge(&rotated, false, 0));
|
||||||
|
|
||||||
|
// The same with a session init between the late record and the poll.
|
||||||
|
let mut l2 = WaylandLayout::default();
|
||||||
|
l2.reset_baseline(Vec::new());
|
||||||
|
l2.observe(&rotated);
|
||||||
|
l2.note_capturer(&upright, 0);
|
||||||
|
l2.reset_baseline(rotated.clone());
|
||||||
|
assert!(l2.edge(&rotated, false, 0));
|
||||||
|
|
||||||
|
// Control: a late record that agrees with the poll's memory is not an edge.
|
||||||
|
let mut l3 = WaylandLayout::default();
|
||||||
|
l3.reset_baseline(Vec::new());
|
||||||
|
l3.observe(&upright);
|
||||||
|
l3.note_capturer(&upright, 0);
|
||||||
|
assert!(!l3.edge(&upright, false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The late record can also land after the poll consumed the edge but before the bump that
|
||||||
|
// edge promotes, or after the bump with a snapshot taken before it. That capturer is stale
|
||||||
|
// by generation and rebuilds on its own, so its record must not buy a second promotion
|
||||||
|
// that tears the freshly rebuilt capturers down again.
|
||||||
|
#[test]
|
||||||
|
fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() {
|
||||||
|
let upright = layout(1920, 1080, 0);
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(upright.clone());
|
||||||
|
l.observe(&upright);
|
||||||
|
// The output rotates, the poll consumes the edge, the capturer built on upright at
|
||||||
|
// generation 7 records late, and the poll promotes to 8.
|
||||||
|
assert!(l.edge(&rotated, false, 7));
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.note_capturer(&upright, 7);
|
||||||
|
l.reset_baseline(rotated.clone());
|
||||||
|
assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own");
|
||||||
|
|
||||||
|
// Control: a disagreeing record AT the promoted generation is a real edge.
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.note_capturer(&upright, 8);
|
||||||
|
assert!(l.edge(&rotated, false, 8));
|
||||||
|
|
||||||
|
// A stale record landing after a fresh one must not hide the fresh one.
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.note_capturer(&upright, 8);
|
||||||
|
l.note_capturer(&upright, 7);
|
||||||
|
assert!(l.edge(&rotated, false, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A promotion consumes the edge: the next poll sees the same layout and must stay quiet.
|
||||||
|
#[test]
|
||||||
|
fn a_promoted_layout_is_not_an_edge_again() {
|
||||||
|
let rotated = layout(1080, 1920, 1);
|
||||||
|
let mut l = WaylandLayout::default();
|
||||||
|
l.reset_baseline(layout(1920, 1080, 0));
|
||||||
|
l.observe(&rotated);
|
||||||
|
l.reset_baseline(rotated.clone());
|
||||||
|
assert!(!l.edge(&rotated, false, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,9 +52,17 @@ impl FrameSlot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Shared.transform` before new() stores the real value: a cursor arriving this early is held
|
||||||
|
/// back and replayed once the session transform is in, because the producer will not resend it
|
||||||
|
/// until the shape changes.
|
||||||
|
const TRANSFORM_PENDING: i32 = i32::MIN;
|
||||||
|
|
||||||
struct Shared {
|
struct Shared {
|
||||||
slot: Mutex<FrameSlot>,
|
slot: Mutex<FrameSlot>,
|
||||||
cv: Condvar,
|
cv: Condvar,
|
||||||
|
// Session transform, TRANSFORM_PENDING until new() stores it post-handshake; the receive
|
||||||
|
// thread turns cursor bitmaps with it and defers any cursor that races the store.
|
||||||
|
transform: std::sync::atomic::AtomicI32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct IpcDrmCapturer {
|
pub struct IpcDrmCapturer {
|
||||||
@@ -63,7 +71,14 @@ pub struct IpcDrmCapturer {
|
|||||||
display: i32,
|
display: i32,
|
||||||
connector: Option<String>,
|
connector: Option<String>,
|
||||||
// What the encoder was sized from: CapturerInfo{width,height} is read once, at build time.
|
// What the encoder was sized from: CapturerInfo{width,height} is read once, at build time.
|
||||||
|
// With a rotated output these are the ROTATED dimensions, matching the frames delivered.
|
||||||
session_size: Option<(usize, usize)>,
|
session_size: Option<(usize, usize)>,
|
||||||
|
// Output rotation in degrees: a rotated scanout holds the desktop drawn sideways, so frames
|
||||||
|
// are turned back before delivery. Fixed per session; a rotation rebuilds the capturer.
|
||||||
|
transform: i32,
|
||||||
|
// The wayland snapshot generation this session was built from: a later invalidation means
|
||||||
|
// the layout (a rotation included) may have changed, and frame() asks for a rebuild.
|
||||||
|
snapshot_gen: u64,
|
||||||
cur: Vec<u8>,
|
cur: Vec<u8>,
|
||||||
cur_w: usize,
|
cur_w: usize,
|
||||||
cur_h: usize,
|
cur_h: usize,
|
||||||
@@ -76,6 +91,102 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
|
|||||||
format!("{}:{}", d.device, d.name)
|
format!("{}:{}", d.device, d.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Frame dimensions after undoing `transform` degrees of output rotation.
|
||||||
|
fn rotated_dims(transform: i32, w: usize, h: usize) -> (usize, usize) {
|
||||||
|
if transform == 90 || transform == 270 {
|
||||||
|
(h, w)
|
||||||
|
} else {
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hotspot of a rotated cursor bitmap: the same point mapping `unrotate_bgra` applies to
|
||||||
|
/// pixels, applied to the one coordinate that must keep naming the click point.
|
||||||
|
fn unrotate_hotspot(transform: i32, w: i32, h: i32, hotx: i32, hoty: i32) -> (i32, i32) {
|
||||||
|
match transform {
|
||||||
|
90 => (h - 1 - hoty, hotx),
|
||||||
|
180 => (w - 1 - hotx, h - 1 - hoty),
|
||||||
|
270 => (hoty, w - 1 - hotx),
|
||||||
|
_ => (hotx, hoty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a 4-byte-pixel frame upright into tightly packed `dst`, undoing `transform` degrees;
|
||||||
|
/// padded `src` rows ok (stride = len/h). Direction pinned by the tests to the measured anchor
|
||||||
|
/// of rustdesk#15886; libyuv walks pixels, so channel order does not matter.
|
||||||
|
fn unrotate_bgra(src: &[u8], w: usize, h: usize, transform: i32, dst: &mut Vec<u8>) {
|
||||||
|
const PX: usize = 4;
|
||||||
|
let stride = if h > 0 { src.len() / h } else { 0 };
|
||||||
|
let (dw, dh) = rotated_dims(transform, w, h);
|
||||||
|
dst.resize(
|
||||||
|
dw.checked_mul(dh).and_then(|p| p.checked_mul(PX)).unwrap_or(0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if dst.is_empty() || stride < w * PX {
|
||||||
|
log::error!("unrotate: rejected geometry {w}x{h} stride {stride}; frame left blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mode = match transform {
|
||||||
|
90 => scrap::RotationMode::kRotate90,
|
||||||
|
180 => scrap::RotationMode::kRotate180,
|
||||||
|
270 => scrap::RotationMode::kRotate270,
|
||||||
|
_ => scrap::RotationMode::kRotate0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
scrap::ARGBRotate(
|
||||||
|
src.as_ptr(),
|
||||||
|
stride as i32,
|
||||||
|
dst.as_mut_ptr(),
|
||||||
|
(dw * PX) as i32,
|
||||||
|
w as i32,
|
||||||
|
h as i32,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform and augmented origin for one wire entry, derived from ONE wayland snapshot so both
|
||||||
|
/// reflect the same output assignment; two `get_displays()` reads could straddle a cache
|
||||||
|
/// invalidation. `None` origin means nothing to augment with (caller keeps the DRM origin).
|
||||||
|
fn transform_and_origin(
|
||||||
|
drm: &[DrmDisplayInfo],
|
||||||
|
wire_idx: usize,
|
||||||
|
wl: &scrap::wayland::display::Displays,
|
||||||
|
) -> (i32, Option<(i32, i32)>) {
|
||||||
|
if wl.displays.is_empty() || (wl.displays.len() == 1 && drm.len() > 1) {
|
||||||
|
if wl.displays.is_empty() && !drm.is_empty() {
|
||||||
|
// A later successful enumeration refills the cache and hides this state from
|
||||||
|
// wayland_snapshot_missing, so the layout poll needs this durable record to know a
|
||||||
|
// capturer was built blind and owes a rebuild.
|
||||||
|
UNROTATED_SNAPSHOT_PENDING.store(true, Ordering::Release);
|
||||||
|
log::warn!(
|
||||||
|
"drm: no wayland snapshot at capturer build for display {:?}; assuming unrotated",
|
||||||
|
drm.get(wire_idx).map(|d| d.name.as_str()).unwrap_or("?")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (0, None);
|
||||||
|
}
|
||||||
|
let assignment = assign_wayland_outputs(drm, &wl.displays);
|
||||||
|
// The transform comes ONLY from an identity match (name, or unique resolution), through the
|
||||||
|
// SAME progressive-taken pass the advertise side keys its swap off: the layout-order
|
||||||
|
// fallback is fine for an origin guess, but a rotation pinned on a guess splits the
|
||||||
|
// advertised dimensions from the delivered ones.
|
||||||
|
let transform = identity_matches(drm, &wl.displays)
|
||||||
|
.get(wire_idx)
|
||||||
|
.copied()
|
||||||
|
.flatten()
|
||||||
|
.map(|j| wl.displays[j].transform)
|
||||||
|
// Hardware-rotated 180 scans out already upright (i915 advertises rotate-180 and
|
||||||
|
// mutter uses it), and wl_output cannot tell hardware from software rotation, so 180
|
||||||
|
// keeps master behavior until the plane rotation property travels the wire.
|
||||||
|
.map(|t| if t == 90 || t == 270 { t } else { 0 })
|
||||||
|
.unwrap_or(0);
|
||||||
|
let origin = augment_with_wayland_geometry_from(drm, wl, &assignment)
|
||||||
|
.get(wire_idx)
|
||||||
|
.map(|di| (di.x, di.y));
|
||||||
|
(transform, origin)
|
||||||
|
}
|
||||||
|
|
||||||
/// Takes DRM_STATE: never call it while holding one of the per-display maps below.
|
/// Takes DRM_STATE: never call it while holding one of the per-display maps below.
|
||||||
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
|
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
|
||||||
match &*DRM_STATE.lock().unwrap() {
|
match &*DRM_STATE.lock().unwrap() {
|
||||||
@@ -96,6 +207,9 @@ struct DisplayHealth {
|
|||||||
/// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node
|
/// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node
|
||||||
/// is not the GPU that exported the scanout. Follows the monitor for the process run.
|
/// is not the GPU that exported the scanout. Follows the monitor for the process run.
|
||||||
prefer_cpu: bool,
|
prefer_cpu: bool,
|
||||||
|
/// The PipeWire fallback for this display was rejected on geometry (a transposed stream), so
|
||||||
|
/// the lone-display carve-out in `mark_demoted_displays` must not keep advertising it online.
|
||||||
|
fallback_rejected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DisplayHealth {
|
impl DisplayHealth {
|
||||||
@@ -107,6 +221,7 @@ impl DisplayHealth {
|
|||||||
last_build: None,
|
last_build: None,
|
||||||
rapid_builds: 0,
|
rapid_builds: 0,
|
||||||
prefer_cpu: false,
|
prefer_cpu: false,
|
||||||
|
fallback_rejected: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +300,14 @@ fn render_node_count() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
/// A capturer was built with no wayland snapshot and runs unrotated; the layout poll consumes
|
||||||
|
/// this to bump the generation once a live snapshot exists.
|
||||||
|
static UNROTATED_SNAPSHOT_PENDING: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub(super) fn take_unrotated_snapshot_pending() -> bool {
|
||||||
|
UNROTATED_SNAPSHOT_PENDING.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||||
|
}
|
||||||
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
impl IpcDrmCapturer {
|
impl IpcDrmCapturer {
|
||||||
@@ -193,7 +316,7 @@ impl IpcDrmCapturer {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
display: i32,
|
display: i32,
|
||||||
expected: Option<DrmDisplayInfo>,
|
expected: Option<DrmDisplayInfo>,
|
||||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
|
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize, Option<(i32, i32)>)> {
|
||||||
let shared = Arc::new(Shared {
|
let shared = Arc::new(Shared {
|
||||||
slot: Mutex::new(FrameSlot {
|
slot: Mutex::new(FrameSlot {
|
||||||
latest: None,
|
latest: None,
|
||||||
@@ -201,6 +324,7 @@ impl IpcDrmCapturer {
|
|||||||
ended: None,
|
ended: None,
|
||||||
}),
|
}),
|
||||||
cv: Condvar::new(),
|
cv: Condvar::new(),
|
||||||
|
transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING),
|
||||||
});
|
});
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
|
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
|
||||||
@@ -220,6 +344,18 @@ impl IpcDrmCapturer {
|
|||||||
bail!("drm capture handshake timed out");
|
bail!("drm capture handshake timed out");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// One snapshot for the session: transform, origin and the advertised swap must all
|
||||||
|
// reflect the same output assignment. The generation is read BEFORE the snapshot, so a
|
||||||
|
// clear racing the build rebuilds once instead of running a session on stale geometry.
|
||||||
|
let snapshot_gen = scrap::wayland::display::wayland_snapshot_generation();
|
||||||
|
let wl = scrap::wayland::display::get_displays();
|
||||||
|
let (transform, origin) = transform_and_origin(&displays, wire_idx, &wl);
|
||||||
|
// This capturer now shows that layout. If the session init's own wayland query failed it
|
||||||
|
// saved an empty baseline, so this is the only record of what the stream is built on.
|
||||||
|
super::display_service::note_capturer_layout(&wl.displays, snapshot_gen);
|
||||||
|
shared
|
||||||
|
.transform
|
||||||
|
.store(transform, std::sync::atomic::Ordering::Release);
|
||||||
Ok((
|
Ok((
|
||||||
IpcDrmCapturer {
|
IpcDrmCapturer {
|
||||||
shared,
|
shared,
|
||||||
@@ -228,7 +364,9 @@ impl IpcDrmCapturer {
|
|||||||
connector: displays.get(wire_idx).map(connector_key),
|
connector: displays.get(wire_idx).map(connector_key),
|
||||||
session_size: displays
|
session_size: displays
|
||||||
.get(wire_idx)
|
.get(wire_idx)
|
||||||
.map(|d| (d.width as usize, d.height as usize)),
|
.map(|d| rotated_dims(transform, d.width as usize, d.height as usize)),
|
||||||
|
transform,
|
||||||
|
snapshot_gen,
|
||||||
cur: Vec::new(),
|
cur: Vec::new(),
|
||||||
cur_w: 0,
|
cur_w: 0,
|
||||||
cur_h: 0,
|
cur_h: 0,
|
||||||
@@ -237,6 +375,7 @@ impl IpcDrmCapturer {
|
|||||||
},
|
},
|
||||||
displays,
|
displays,
|
||||||
wire_idx,
|
wire_idx,
|
||||||
|
origin,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +433,21 @@ impl TraitCapturer for IpcDrmCapturer {
|
|||||||
}
|
}
|
||||||
if let Some((w, h, fmt, buf)) = slot.latest.take() {
|
if let Some((w, h, fmt, buf)) = slot.latest.take() {
|
||||||
drop(slot);
|
drop(slot);
|
||||||
// convert_to_yuv only refuses a source LARGER than its destination, so a smaller
|
// A layout change bumps the generation and is otherwise invisible here (mode
|
||||||
// frame leaves stale edges on screen. On the FIRST frame nothing changed: the list
|
// and framebuffer keep their size). Rebuild for the new transform; not counted
|
||||||
// carries the CRTC mode, a frame the scanout fb, different when a CRTC scales.
|
// against health: the layout moved, the display did not fail.
|
||||||
if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) {
|
if scrap::wayland::display::wayland_snapshot_generation() != self.snapshot_gen {
|
||||||
|
self.shared.slot.lock().unwrap().recycle(buf);
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
format!("drm: display {} layout changed; rebuilding", self.display),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Frames arrive in scanout orientation, the session was sized rotated, so the
|
||||||
|
// guard compares rotated dims. convert_to_yuv only refuses a LARGER source (a
|
||||||
|
// smaller one leaves stale edges); first frame: CRTC mode vs scanout fb.
|
||||||
|
let (fw, fh) = rotated_dims(self.transform, w, h);
|
||||||
|
if self.session_size.is_some_and(|(sw, sh)| (fw, fh) != (sw, sh)) {
|
||||||
self.shared.slot.lock().unwrap().recycle(buf);
|
self.shared.slot.lock().unwrap().recycle(buf);
|
||||||
if !self.got_frame {
|
if !self.got_frame {
|
||||||
self.note_session_without_frame();
|
self.note_session_without_frame();
|
||||||
@@ -311,15 +461,35 @@ impl TraitCapturer for IpcDrmCapturer {
|
|||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::Other,
|
io::ErrorKind::Other,
|
||||||
format!(
|
format!(
|
||||||
"drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding",
|
"drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding",
|
||||||
self.display
|
self.display
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let previous = std::mem::replace(&mut self.cur, buf);
|
if self.transform == 0 {
|
||||||
self.shared.slot.lock().unwrap().recycle(previous);
|
let previous = std::mem::replace(&mut self.cur, buf);
|
||||||
self.cur_w = w;
|
self.shared.slot.lock().unwrap().recycle(previous);
|
||||||
self.cur_h = h;
|
} else if !matches!(fmt, Pixfmt::BGRA | Pixfmt::RGBA) {
|
||||||
|
// Unreachable with today's producers (the convert path emits 4-byte pixels
|
||||||
|
// and the CPU path hardcodes BGRA); kept so a future non-4-byte producer
|
||||||
|
// fails the session instead of shearing the image.
|
||||||
|
self.shared.slot.lock().unwrap().recycle(buf);
|
||||||
|
if !self.got_frame {
|
||||||
|
self.note_session_without_frame();
|
||||||
|
}
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
format!(
|
||||||
|
"drm: display {} delivered {fmt:?} on a rotated output; rebuilding",
|
||||||
|
self.display
|
||||||
|
),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
unrotate_bgra(&buf, w, h, self.transform, &mut self.cur);
|
||||||
|
self.shared.slot.lock().unwrap().recycle(buf);
|
||||||
|
}
|
||||||
|
self.cur_w = fw;
|
||||||
|
self.cur_h = fh;
|
||||||
self.cur_fmt = fmt;
|
self.cur_fmt = fmt;
|
||||||
if !self.got_frame {
|
if !self.got_frame {
|
||||||
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
|
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
|
||||||
@@ -330,6 +500,7 @@ impl TraitCapturer for IpcDrmCapturer {
|
|||||||
h.zero_frame_streak = 0;
|
h.zero_frame_streak = 0;
|
||||||
h.demotes = 0;
|
h.demotes = 0;
|
||||||
h.since = Instant::now();
|
h.since = Instant::now();
|
||||||
|
h.fallback_rejected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,10 +631,21 @@ async fn recv_thread(
|
|||||||
}
|
}
|
||||||
let _ = tx.send(Ok((displays, wire_idx)));
|
let _ = tx.send(Ok((displays, wire_idx)));
|
||||||
|
|
||||||
|
// A cursor that arrived before new() stored the session transform, held for replay. Only the
|
||||||
|
// newest matters; the 200 ms recv timeout guarantees this is retried even on an idle wire.
|
||||||
|
let mut pending_cursor: Option<(u64, u32, u32, i32, i32, Vec<u8>)> = None;
|
||||||
let end_reason = loop {
|
let end_reason = loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
break "stopped".to_owned();
|
break "stopped".to_owned();
|
||||||
}
|
}
|
||||||
|
if pending_cursor.is_some() {
|
||||||
|
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||||
|
if t != TRANSFORM_PENDING {
|
||||||
|
if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() {
|
||||||
|
deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await {
|
let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await {
|
||||||
None => continue, // timeout: re-check stop at the loop top
|
None => continue, // timeout: re-check stop at the loop top
|
||||||
Some(Ok(pair)) => pair,
|
Some(Ok(pair)) => pair,
|
||||||
@@ -580,18 +762,23 @@ async fn recv_thread(
|
|||||||
raw.len()
|
raw.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
set_drm_cursor(
|
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||||
display,
|
if t == TRANSFORM_PENDING {
|
||||||
cursor_epoch,
|
pending_cursor = Some((id, width, height, hotx, hoty, raw));
|
||||||
DrmCursorData {
|
} else {
|
||||||
|
pending_cursor = None;
|
||||||
|
deliver_drm_cursor(
|
||||||
|
display,
|
||||||
|
cursor_epoch,
|
||||||
id,
|
id,
|
||||||
width: width as i32,
|
width,
|
||||||
height: height as i32,
|
height,
|
||||||
hotx,
|
hotx,
|
||||||
hoty,
|
hoty,
|
||||||
colors: raw,
|
raw,
|
||||||
},
|
t,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => break format!("cursor body: {err}"),
|
Ok(Err(err)) => break format!("cursor body: {err}"),
|
||||||
}
|
}
|
||||||
@@ -717,6 +904,56 @@ fn remove_drm_cursor(display: i32, epoch: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unrotate a wire cursor into the session orientation and publish it. The compositor
|
||||||
|
/// pre-rotates the bitmap it programs into the cursor plane, so over the unrotated video the
|
||||||
|
/// cursor alone would stay turned and its hotspot transposed (review finding 11 on
|
||||||
|
/// rustdesk#15889). The wire id hashes only the plane pixels and geometry, so a stream rebuilt
|
||||||
|
/// under a new transform resends the SAME id and the client's by-id cursor cache would keep the
|
||||||
|
/// old orientation: fold the transform in (the producer's own FNV step) so id and orientation
|
||||||
|
/// can never disagree. The hidden sentinel must survive untouched.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn deliver_drm_cursor(
|
||||||
|
display: i32,
|
||||||
|
cursor_epoch: u64,
|
||||||
|
id: u64,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
hotx: i32,
|
||||||
|
hoty: i32,
|
||||||
|
raw: Vec<u8>,
|
||||||
|
t: i32,
|
||||||
|
) {
|
||||||
|
let (width, height, hotx, hoty, colors) = if t == 90 || t == 270 {
|
||||||
|
let mut turned = Vec::new();
|
||||||
|
unrotate_bgra(&raw, width as usize, height as usize, t, &mut turned);
|
||||||
|
let (hx, hy) = unrotate_hotspot(t, width as i32, height as i32, hotx, hoty);
|
||||||
|
(height as i32, width as i32, hx, hy, turned)
|
||||||
|
} else {
|
||||||
|
(width as i32, height as i32, hotx, hoty, raw)
|
||||||
|
};
|
||||||
|
let id = fold_cursor_id(id, t);
|
||||||
|
set_drm_cursor(
|
||||||
|
display,
|
||||||
|
cursor_epoch,
|
||||||
|
DrmCursorData {
|
||||||
|
id,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hotx,
|
||||||
|
hoty,
|
||||||
|
colors,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_cursor_id(id: u64, t: i32) -> u64 {
|
||||||
|
if id == scrap::drm_reader::HIDDEN_CURSOR_ID {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
(id ^ t as u32 as u64).wrapping_mul(1099511628211)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
||||||
let map = DRM_CURSOR.lock().unwrap();
|
let map = DRM_CURSOR.lock().unwrap();
|
||||||
map.values()
|
map.values()
|
||||||
@@ -1183,12 +1420,22 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it
|
// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it
|
||||||
// offline; a single connector remains usable through the whole-desktop fallback.
|
// offline; a single connector remains usable through the whole-desktop fallback - unless that
|
||||||
|
// fallback itself was rejected on geometry, in which case advertising the lone display online
|
||||||
|
// would restart-loop the video service against a stream nothing can serve.
|
||||||
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
||||||
|
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
||||||
if list.len() <= 1 {
|
if list.len() <= 1 {
|
||||||
|
if let (Some(display), Some(info)) = (list.first(), infos.first_mut()) {
|
||||||
|
if health
|
||||||
|
.get(&connector_key(display))
|
||||||
|
.is_some_and(|health| health.demoted() && health.fallback_rejected)
|
||||||
|
{
|
||||||
|
info.online = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
|
|
||||||
for (display, info) in list.iter().zip(infos.iter_mut()) {
|
for (display, info) in list.iter().zip(infos.iter_mut()) {
|
||||||
if health
|
if health
|
||||||
.get(&connector_key(display))
|
.get(&connector_key(display))
|
||||||
@@ -1199,6 +1446,21 @@ fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The PipeWire fallback for this display was rejected on geometry; recorded so the lone-display
|
||||||
|
/// carve-out above stops advertising a display nothing can serve. Cleared by a delivered frame
|
||||||
|
/// and by the demote-cooldown re-arm.
|
||||||
|
pub(super) fn mark_fallback_rejected(display_idx: usize) {
|
||||||
|
let Some(expected) = display_info_of(display_idx as i32) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
DRM_DISPLAY_HEALTH
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(connector_key(&expected))
|
||||||
|
.or_insert_with(DisplayHealth::new)
|
||||||
|
.fallback_rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
fn primary_index_from_assignment(assignment: &[Option<usize>], primary: usize) -> usize {
|
fn primary_index_from_assignment(assignment: &[Option<usize>], primary: usize) -> usize {
|
||||||
assignment
|
assignment
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1266,18 +1528,36 @@ fn augment_with_wayland_geometry_from(
|
|||||||
if origin_only && drm.len() > 1 {
|
if origin_only && drm.len() > 1 {
|
||||||
return infos;
|
return infos;
|
||||||
}
|
}
|
||||||
|
let identity = identity_matches(drm, &wl.displays);
|
||||||
for (i, info) in infos.iter_mut().enumerate() {
|
for (i, info) in infos.iter_mut().enumerate() {
|
||||||
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
|
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
info.x = w.x;
|
info.x = w.x;
|
||||||
info.y = w.y;
|
info.y = w.y;
|
||||||
|
// Rotated size before the origin-only cut: a lone rotated output still delivers rotated
|
||||||
|
// frames, so it must advertise them; only the logical-scale adoption stays multi-output.
|
||||||
|
// original_resolution follows in the same motion, or the client reads the transposed
|
||||||
|
// current size against an untransposed original as a third-party resolution change.
|
||||||
|
// Identity matches ONLY, the same rule the capturer's transform follows: swapping on a
|
||||||
|
// layout-order guess advertises dimensions the capturer will not deliver.
|
||||||
|
let is_identity = identity[i].is_some() && identity[i] == matched[i];
|
||||||
|
if is_identity && (w.transform == 90 || w.transform == 270) {
|
||||||
|
std::mem::swap(&mut info.width, &mut info.height);
|
||||||
|
info.original_resolution = super::display_service::get_original_resolution(
|
||||||
|
&drm[i].name,
|
||||||
|
info.width as usize,
|
||||||
|
info.height as usize,
|
||||||
|
);
|
||||||
|
}
|
||||||
if origin_only {
|
if origin_only {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((lw, lh)) = w.logical_size {
|
if let Some((lw, lh)) = w.logical_size {
|
||||||
if lw > 0 && lh > 0 {
|
if lw > 0 && lh > 0 {
|
||||||
info.scale = drm[i].width as f64 / lw as f64;
|
// Post-swap width over logical width, which arrives already swapped when rotated:
|
||||||
|
// the unrotated numerator made a rotated 1:1 monitor advertise scale 16/9.
|
||||||
|
info.scale = info.width as f64 / lw as f64;
|
||||||
info.original_resolution = super::display_service::get_original_resolution(
|
info.original_resolution = super::display_service::get_original_resolution(
|
||||||
&drm[i].name,
|
&drm[i].name,
|
||||||
lw as usize,
|
lw as usize,
|
||||||
@@ -1292,18 +1572,62 @@ fn augment_with_wayland_geometry_from(
|
|||||||
/// Each output goes to at most one connector; unmatched ones take the next free output of the same
|
/// Each output goes to at most one connector; unmatched ones take the next free output of the same
|
||||||
/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at
|
/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at
|
||||||
/// DRM's (0,0).
|
/// DRM's (0,0).
|
||||||
fn assign_wayland_outputs(
|
/// The identity half of the assignment (name, or unique resolution), same progressive `taken`
|
||||||
|
/// as the full one. Rotation keys off THIS on both sides: swapping or turning on a layout-order
|
||||||
|
/// guess splits the advertised dimensions from the delivered frames.
|
||||||
|
/// Identity assignment in two GLOBAL passes: every exact name match is reserved first, then
|
||||||
|
/// resolution pairing runs on the unmatched remainder, and only when it is forced - exactly one
|
||||||
|
/// free output AND exactly one unmatched connector at that resolution. A resolution guess for an
|
||||||
|
/// earlier connector must never steal an exact name match from a later one.
|
||||||
|
fn identity_matches(
|
||||||
drm: &[DrmDisplayInfo],
|
drm: &[DrmDisplayInfo],
|
||||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||||
) -> Vec<Option<usize>> {
|
) -> Vec<Option<usize>> {
|
||||||
let mut taken = vec![false; wl.len()];
|
let mut taken = vec![false; wl.len()];
|
||||||
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
|
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
|
||||||
for (i, d) in drm.iter().enumerate() {
|
for (i, d) in drm.iter().enumerate() {
|
||||||
if let Some(j) = match_wayland_display(d, wl, &taken) {
|
let dn = normalize_connector(&d.name);
|
||||||
|
if let Some((j, _)) = wl
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
||||||
|
{
|
||||||
matched[i] = Some(j);
|
matched[i] = Some(j);
|
||||||
taken[j] = true;
|
taken[j] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (i, d) in drm.iter().enumerate() {
|
||||||
|
if matched[i].is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let free_same: Vec<usize> = wl
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
||||||
|
.map(|(j, _)| j)
|
||||||
|
.collect();
|
||||||
|
let unmatched_same = drm
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(k, o)| matched[*k].is_none() && o.width == d.width && o.height == d.height)
|
||||||
|
.count();
|
||||||
|
if free_same.len() == 1 && unmatched_same == 1 {
|
||||||
|
matched[i] = Some(free_same[0]);
|
||||||
|
taken[free_same[0]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_wayland_outputs(
|
||||||
|
drm: &[DrmDisplayInfo],
|
||||||
|
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
||||||
|
) -> Vec<Option<usize>> {
|
||||||
|
let mut matched = identity_matches(drm, wl);
|
||||||
|
let mut taken = vec![false; wl.len()];
|
||||||
|
for m in matched.iter().flatten() {
|
||||||
|
taken[*m] = true;
|
||||||
|
}
|
||||||
for (i, d) in drm.iter().enumerate() {
|
for (i, d) in drm.iter().enumerate() {
|
||||||
if matched[i].is_some() {
|
if matched[i].is_some() {
|
||||||
continue;
|
continue;
|
||||||
@@ -1329,30 +1653,6 @@ fn assign_wayland_outputs(
|
|||||||
matched
|
matched
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_wayland_display(
|
|
||||||
d: &DrmDisplayInfo,
|
|
||||||
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
|
|
||||||
taken: &[bool],
|
|
||||||
) -> Option<usize> {
|
|
||||||
let dn = normalize_connector(&d.name);
|
|
||||||
if let Some((j, _)) = wl
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
|
|
||||||
{
|
|
||||||
return Some(j);
|
|
||||||
}
|
|
||||||
let same_res: Vec<usize> = wl
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32)
|
|
||||||
.map(|(j, _)| j)
|
|
||||||
.collect();
|
|
||||||
if same_res.len() == 1 {
|
|
||||||
return Some(same_res[0]);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1").
|
/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1").
|
||||||
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
|
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
|
||||||
@@ -1413,11 +1713,13 @@ pub(super) fn get_capturer_info(
|
|||||||
}
|
}
|
||||||
h.zero_frame_streak = 0;
|
h.zero_frame_streak = 0;
|
||||||
h.since = Instant::now();
|
h.since = Instant::now();
|
||||||
|
// The cooldown re-arms DRM for this display, so the fallback verdict restarts too.
|
||||||
|
h.fallback_rejected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below.
|
// Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below.
|
||||||
let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||||
// The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window.
|
// The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window.
|
||||||
if let Some(key) = key.clone() {
|
if let Some(key) = key.clone() {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -1445,16 +1747,14 @@ pub(super) fn get_capturer_info(
|
|||||||
.get(wire_idx)
|
.get(wire_idx)
|
||||||
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
|
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
|
||||||
.clone();
|
.clone();
|
||||||
// Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin
|
// Origin and transform come from the ONE snapshot new() resolved, so both reflect the
|
||||||
// matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer.
|
// same output assignment; dimensions stay PHYSICAL, rotated to frame orientation.
|
||||||
let origin = augment_with_wayland_geometry(&displays)
|
let origin = origin.unwrap_or((d.x, d.y));
|
||||||
.get(wire_idx)
|
let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize);
|
||||||
.map(|di| (di.x, di.y))
|
|
||||||
.unwrap_or((d.x, d.y));
|
|
||||||
Ok(super::video_service::CapturerInfo {
|
Ok(super::video_service::CapturerInfo {
|
||||||
origin,
|
origin,
|
||||||
width: d.width as usize,
|
width: cap_w,
|
||||||
height: d.height as usize,
|
height: cap_h,
|
||||||
ndisplay,
|
ndisplay,
|
||||||
current: display_idx,
|
current: display_idx,
|
||||||
privacy_mode_id: 0,
|
privacy_mode_id: 0,
|
||||||
@@ -1482,11 +1782,14 @@ mod drm_capturer_tests {
|
|||||||
ended: None,
|
ended: None,
|
||||||
}),
|
}),
|
||||||
cv: Condvar::new(),
|
cv: Condvar::new(),
|
||||||
|
transform: std::sync::atomic::AtomicI32::new(0),
|
||||||
}),
|
}),
|
||||||
stop: Arc::new(AtomicBool::new(false)),
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
display: 0,
|
display: 0,
|
||||||
connector,
|
connector,
|
||||||
session_size: session,
|
session_size: session,
|
||||||
|
transform: 0,
|
||||||
|
snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(),
|
||||||
cur: Vec::new(),
|
cur: Vec::new(),
|
||||||
cur_w: 0,
|
cur_w: 0,
|
||||||
cur_h: 0,
|
cur_h: 0,
|
||||||
@@ -1495,6 +1798,172 @@ mod drm_capturer_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One BGRA pixel per label byte, so a rotation result reads as a matrix of labels.
|
||||||
|
fn px_frame(labels: &[&[u8]], pad_bytes: usize) -> (Vec<u8>, usize, usize) {
|
||||||
|
let h = labels.len();
|
||||||
|
let w = labels[0].len();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
for row in labels {
|
||||||
|
for &l in *row {
|
||||||
|
buf.extend_from_slice(&[l, l, l, 255]);
|
||||||
|
}
|
||||||
|
buf.extend(std::iter::repeat(0u8).take(pad_bytes));
|
||||||
|
}
|
||||||
|
(buf, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labels_of(buf: &[u8], w: usize, h: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..h)
|
||||||
|
.map(|y| (0..w).map(|x| buf[(y * w + x) * 4]).collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() {
|
||||||
|
// Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide.
|
||||||
|
let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)];
|
||||||
|
let key = connector_key(&list[0]);
|
||||||
|
let demoted = DisplayHealth {
|
||||||
|
zero_frame_streak: DRM_GRAB_MAX_FAILURES,
|
||||||
|
demotes: 1,
|
||||||
|
..DisplayHealth::new()
|
||||||
|
};
|
||||||
|
// Demoted alone keeps the lone display online: the whole-desktop fallback is usable.
|
||||||
|
DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted);
|
||||||
|
let mut infos = vec![DisplayInfo {
|
||||||
|
online: true,
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
mark_demoted_displays(&list, &mut infos);
|
||||||
|
assert!(infos[0].online, "the lone-display carve-out must survive");
|
||||||
|
// A rejected fallback ends the carve-out: advertising online would restart-loop.
|
||||||
|
DRM_DISPLAY_HEALTH
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get_mut(&key)
|
||||||
|
.expect("just inserted")
|
||||||
|
.fallback_rejected = true;
|
||||||
|
mark_demoted_displays(&list, &mut infos);
|
||||||
|
assert!(!infos[0].online, "a rejected fallback must take the lone display offline");
|
||||||
|
// Once the demotion cooldown lapses the display is no longer demoted, and online returns
|
||||||
|
// even with the rejection still latched (the re-arm will clear it on the next build).
|
||||||
|
DRM_DISPLAY_HEALTH
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get_mut(&key)
|
||||||
|
.expect("still there")
|
||||||
|
.since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1);
|
||||||
|
infos[0].online = true;
|
||||||
|
mark_demoted_displays(&list, &mut infos);
|
||||||
|
assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cursor_id_names_the_orientation_too() {
|
||||||
|
// Same wire cursor under two transforms must publish as two ids, or the client's by-id
|
||||||
|
// cache serves the previous orientation after a mid-session rotation.
|
||||||
|
let wire = 0xDEAD_BEEF_u64;
|
||||||
|
assert_ne!(fold_cursor_id(wire, 0), fold_cursor_id(wire, 90));
|
||||||
|
assert_ne!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 270));
|
||||||
|
// Deterministic per (id, transform), so an unchanged cursor is still deduped.
|
||||||
|
assert_eq!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 90));
|
||||||
|
// The hidden sentinel is compared by VALUE at the consumers, so it must pass unfolded.
|
||||||
|
let hidden = scrap::drm_reader::HIDDEN_CURSOR_ID;
|
||||||
|
assert_eq!(fold_cursor_id(hidden, 90), hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrotate_hotspot_follows_the_pixel_mapping() {
|
||||||
|
// 3 wide x 2 tall, hotspot at (2,0) (top-right): after the 90 turn (left column to top
|
||||||
|
// row) that pixel sits at (1,2) in the 2x3 result; 270 sends it to (0,0).
|
||||||
|
assert_eq!(unrotate_hotspot(90, 3, 2, 2, 0), (1, 2));
|
||||||
|
assert_eq!(unrotate_hotspot(270, 3, 2, 2, 0), (0, 0));
|
||||||
|
assert_eq!(unrotate_hotspot(180, 3, 2, 2, 0), (0, 1));
|
||||||
|
assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() {
|
||||||
|
let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild"));
|
||||||
|
c.snapshot_gen = c.snapshot_gen.wrapping_sub(1);
|
||||||
|
put_frame(&c, 64, 32);
|
||||||
|
let err = match c.frame(Duration::from_millis(50)) {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("a stale generation must rebuild, not deliver"),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("layout changed"), "{err}");
|
||||||
|
assert!(!c.got_frame);
|
||||||
|
assert_eq!(
|
||||||
|
zero_frame_streak_of(&c),
|
||||||
|
0,
|
||||||
|
"a layout rebuild must not count against display health"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrotate_90_maps_the_left_column_to_the_top_row() {
|
||||||
|
// The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down
|
||||||
|
// the scanout's LEFT edge, and upright means that edge becomes the TOP row.
|
||||||
|
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||||
|
let mut dst = Vec::new();
|
||||||
|
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||||
|
// src left column top-to-bottom = [1, 4]; clockwise puts it on the top row as [4, 1].
|
||||||
|
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrotate_270_is_the_inverse_of_90() {
|
||||||
|
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||||
|
let mut once = Vec::new();
|
||||||
|
unrotate_bgra(&src, w, h, 90, &mut once);
|
||||||
|
let mut back = Vec::new();
|
||||||
|
unrotate_bgra(&once, h, w, 270, &mut back);
|
||||||
|
assert_eq!(back, src);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrotate_180_reverses_both_axes() {
|
||||||
|
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0);
|
||||||
|
let mut dst = Vec::new();
|
||||||
|
unrotate_bgra(&src, w, h, 180, &mut dst);
|
||||||
|
assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrotate_reads_padded_strides_and_writes_tight() {
|
||||||
|
// Row stride is derived from len/h, so a padded source must not shear the result.
|
||||||
|
let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 8);
|
||||||
|
let mut dst = Vec::new();
|
||||||
|
unrotate_bgra(&src, w, h, 90, &mut dst);
|
||||||
|
assert_eq!(dst.len(), w * h * 4);
|
||||||
|
assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]);
|
||||||
|
let mut plain = Vec::new();
|
||||||
|
unrotate_bgra(&src, w, h, 0, &mut plain);
|
||||||
|
assert_eq!(labels_of(&plain, w, h), vec![vec![1, 2, 3], vec![4, 5, 6]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rotated_session_delivers_rotated_frames_and_guards_in_rotated_dims() {
|
||||||
|
use scrap::TraitPixelBuffer;
|
||||||
|
let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 scanout
|
||||||
|
c.transform = 90;
|
||||||
|
put_frame(&c, 64, 32);
|
||||||
|
match c.frame(Duration::from_millis(50)) {
|
||||||
|
Ok(Frame::PixelBuffer(pb)) => {
|
||||||
|
assert_eq!((pb.width(), pb.height()), (32, 64));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("expected a pixel-buffer frame"),
|
||||||
|
Err(err) => panic!("expected a delivered frame, got {err}"),
|
||||||
|
}
|
||||||
|
// A scanout change still ends the session, reported in rotated dimensions.
|
||||||
|
put_frame(&c, 32, 64);
|
||||||
|
let err = match c.frame(Duration::from_millis(50)) {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("a scanout change must end a rotated session too"),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("(32x64 -> 64x32)"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 {
|
fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 {
|
||||||
let key = c.connector.clone().expect("this check needs an identity");
|
let key = c.connector.clone().expect("this check needs an identity");
|
||||||
DRM_DISPLAY_HEALTH
|
DRM_DISPLAY_HEALTH
|
||||||
@@ -1525,6 +1994,7 @@ mod drm_capturer_tests {
|
|||||||
h.rapid_builds = 3;
|
h.rapid_builds = 3;
|
||||||
h.last_build = Some(Instant::now());
|
h.last_build = Some(Instant::now());
|
||||||
h.prefer_cpu = true;
|
h.prefer_cpu = true;
|
||||||
|
h.fallback_rejected = true;
|
||||||
}
|
}
|
||||||
put_frame(&c, 64, 32);
|
put_frame(&c, 64, 32);
|
||||||
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
|
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
|
||||||
@@ -1537,6 +2007,10 @@ mod drm_capturer_tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak");
|
assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak");
|
||||||
assert_eq!(h.demotes, 0, "and the demotion count that streak drove");
|
assert_eq!(h.demotes, 0, "and the demotion count that streak drove");
|
||||||
|
assert!(
|
||||||
|
!h.fallback_rejected,
|
||||||
|
"a delivered frame also refutes the rejected-fallback verdict"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
h.rapid_builds, 3,
|
h.rapid_builds, 3,
|
||||||
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
|
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
|
||||||
@@ -1642,9 +2116,53 @@ mod drm_capturer_tests {
|
|||||||
height: h,
|
height: h,
|
||||||
logical_size: Some((w, h)),
|
logical_size: Some((w, h)),
|
||||||
refresh_rate: 60,
|
refresh_rate: 60,
|
||||||
|
transform: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lone_rotated_output_advertises_delivered_dimensions() {
|
||||||
|
// Fix for the origin-only cut: one connector, one rotated output. The capturer will
|
||||||
|
// deliver rotated frames, so the advertised size must swap even in the origin-only case,
|
||||||
|
// while the logical scale is still not adopted (stays 1.0).
|
||||||
|
let drm = [drm_display("HDMI-A-1", 1920, 1080)];
|
||||||
|
let mut out = wl_display("HDMI-1", 0, 0, 1920, 1080);
|
||||||
|
out.transform = 90;
|
||||||
|
let wl = scrap::wayland::display::Displays {
|
||||||
|
primary: 0,
|
||||||
|
displays: vec![out],
|
||||||
|
};
|
||||||
|
let assignment = assign_wayland_outputs(&drm, &wl.displays);
|
||||||
|
let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment);
|
||||||
|
assert_eq!((infos[0].width, infos[0].height), (1080, 1920));
|
||||||
|
assert_eq!(infos[0].scale, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transform_and_origin_come_from_the_same_snapshot() {
|
||||||
|
// Both derive from ONE Displays snapshot: the rotated output's transform and its origin
|
||||||
|
// must belong to the same assignment, and the multi-connector one-output guard zeroes
|
||||||
|
// both rather than mixing a guessed origin with a real transform.
|
||||||
|
let drm = [
|
||||||
|
drm_display("HDMI-A-1", 1920, 1080),
|
||||||
|
drm_display("DP-1", 2560, 1440),
|
||||||
|
];
|
||||||
|
let mut rotated = wl_display("DP-1", 1920, 0, 2560, 1440);
|
||||||
|
rotated.transform = 270;
|
||||||
|
let wl = scrap::wayland::display::Displays {
|
||||||
|
primary: 0,
|
||||||
|
displays: vec![rotated, wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||||
|
};
|
||||||
|
let (t, origin) = transform_and_origin(&drm, 1, &wl);
|
||||||
|
assert_eq!(t, 270);
|
||||||
|
assert_eq!(origin, Some((1920, 0)));
|
||||||
|
let lone = scrap::wayland::display::Displays {
|
||||||
|
primary: 0,
|
||||||
|
displays: vec![wl_display("HDMI-1", 0, 0, 1920, 1080)],
|
||||||
|
};
|
||||||
|
assert_eq!(transform_and_origin(&drm, 1, &lone), (0, None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_connector_assignment_drives_geometry_and_primary() {
|
fn one_connector_assignment_drives_geometry_and_primary() {
|
||||||
let drm = [
|
let drm = [
|
||||||
@@ -1725,6 +2243,32 @@ mod drm_capturer_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resolution_guess_never_steals_an_exact_name_match() {
|
||||||
|
// The review's scenario: an earlier connector with an unmatchable name shares the
|
||||||
|
// resolution of a later connector's exact name match. Names reserve globally first.
|
||||||
|
let drm = vec![
|
||||||
|
drm_display("DSI-1", 1920, 1080),
|
||||||
|
drm_display("HDMI-A-1", 1920, 1080),
|
||||||
|
];
|
||||||
|
let wl = vec![
|
||||||
|
wl_display("HDMI-1", 0, 0, 1920, 1080),
|
||||||
|
wl_display("Unknown-9", 1920, 0, 2560, 1440),
|
||||||
|
];
|
||||||
|
let m = identity_matches(&drm, &wl);
|
||||||
|
assert_eq!(m[1], Some(0), "the exact name match must win globally");
|
||||||
|
assert_eq!(m[0], None, "the leftover pairing is not forced, so no identity");
|
||||||
|
// Two unmatched connectors at the lone free resolution: ambiguous on the DRM side too,
|
||||||
|
// so rotation must not be pinned on either.
|
||||||
|
let drm2 = vec![
|
||||||
|
drm_display("DSI-1", 1920, 1080),
|
||||||
|
drm_display("DSI-2", 1920, 1080),
|
||||||
|
];
|
||||||
|
let wl2 = vec![wl_display("HDMI-1", 0, 0, 1920, 1080)];
|
||||||
|
let m2 = identity_matches(&drm2, &wl2);
|
||||||
|
assert!(m2[0].is_none() && m2[1].is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
||||||
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ struct CapDisplayInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
|
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
|
||||||
/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so
|
/// asked. `(minx, maxx, miny, maxy)`, in delivered-orientation physical pixels (a rotated
|
||||||
|
/// output counts transposed, matching its frames): no compositor here applied a scale, so
|
||||||
/// unlike `desktop_rect_of` there is no logical size to handle.
|
/// unlike `desktop_rect_of` there is no logical size to handle.
|
||||||
#[cfg(feature = "drm")]
|
#[cfg(feature = "drm")]
|
||||||
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||||
@@ -521,11 +522,13 @@ pub(super) fn get_capturer_for_display(
|
|||||||
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
|
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
|
||||||
// `try_fix_logical_size` only repairs the capturable's SEPARATE
|
// `try_fix_logical_size` only repairs the capturable's SEPARATE
|
||||||
// `logical_size` field and never touches `physical_size`, so the rect is not
|
// `logical_size` field and never touches `physical_size`, so the rect is not
|
||||||
// logical. The advertised DRM geometry is physical too
|
// logical. The advertised DRM geometry is physical too, in DELIVERED
|
||||||
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
|
// orientation: `augment_with_wayland_geometry` transposes width/height for a
|
||||||
// width/height as the DRM mode). Dividing one side by the scale therefore
|
// 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated
|
||||||
// compares logical against physical and rejects the valid stream on exactly
|
// is UNMEASURED on a rotated display (pipewiresrc does not apply
|
||||||
// the scaled outputs it was meant to rescue.
|
// SPA_META_VideoTransform), so the size half accepts either orientation
|
||||||
|
// rather than gambling a permanent offline on one of them. Dividing a side
|
||||||
|
// by the scale would still be wrong: logical against physical.
|
||||||
//
|
//
|
||||||
// The size check is what tells one connector apart from the whole-desktop
|
// The size check is what tells one connector apart from the whole-desktop
|
||||||
// rect the portal usually exposes. It is skipped only when BOTH sides say
|
// rect the portal usually exposes. It is skipped only when BOTH sides say
|
||||||
@@ -537,15 +540,35 @@ pub(super) fn get_capturer_for_display(
|
|||||||
// a monitor on a card the service cannot open is missing from the DRM list
|
// a monitor on a card the service cannot open is missing from the DRM list
|
||||||
// while the compositor still drives it.
|
// while the compositor still drives it.
|
||||||
let single_display = single_display && cap_display_info.num == 1;
|
let single_display = single_display && cap_display_info.num == 1;
|
||||||
|
// Exact orientation only: a transposed stream would be encoded at the
|
||||||
|
// PipeWire dimensions while the client keeps the advertised (rotated) ones,
|
||||||
|
// and no wayland path ever reconciles the two, so every frame would be
|
||||||
|
// rejected client-side. Falling into the bail instead advertises the display
|
||||||
|
// offline, which the client recovers from by re-enumerating.
|
||||||
|
let size_matches = advertised.width as usize == rect.1
|
||||||
|
&& advertised.height as usize == rect.2;
|
||||||
|
let transposed = advertised.width as usize == rect.2
|
||||||
|
&& advertised.height as usize == rect.1;
|
||||||
|
// The single-display carve-out forgives a size DIFFERENCE (a Full Workspace
|
||||||
|
// stream may report the workspace, not the mode), but never a transposed
|
||||||
|
// pair: that is the same served-vs-advertised orientation split as above,
|
||||||
|
// and it blanks the client the same way.
|
||||||
let consistent = advertised.x == rect.0 .0
|
let consistent = advertised.x == rect.0 .0
|
||||||
&& advertised.y == rect.0 .1
|
&& advertised.y == rect.0 .1
|
||||||
&& (single_display
|
&& (size_matches || (single_display && !transposed));
|
||||||
|| (advertised.width as usize == rect.1
|
|
||||||
&& advertised.height as usize == rect.2));
|
|
||||||
if !consistent {
|
if !consistent {
|
||||||
|
// Recorded so the lone-display carve-out in `mark_demoted_displays` makes
|
||||||
|
// the "advertised offline" below true for a single display too, instead of
|
||||||
|
// restart-looping against a stream nothing can serve.
|
||||||
|
super::drm_capturer::mark_fallback_rejected(display_idx);
|
||||||
bail!(
|
bail!(
|
||||||
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
"drm display {} demoted with no geometry-consistent PipeWire stream{} (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||||
display_idx,
|
display_idx,
|
||||||
|
if transposed {
|
||||||
|
" - stream is transposed vs advertised"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
advertised.width,
|
advertised.width,
|
||||||
advertised.height,
|
advertised.height,
|
||||||
advertised.x,
|
advertised.x,
|
||||||
|
|||||||
Reference in New Issue
Block a user