Compare commits

..

9 Commits

Author SHA1 Message Date
21pages
ce968c02dd scrap: load the D3D compiler securely from System32
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-03 10:36:12 +08:00
21pages
89d444b950 scrap: avoid null duplication after HDR fallback failure
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 23:08:34 +08:00
21pages
792618e59a scrap: log DuplicateOutput1 failures before fallback
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 21:02:09 +08:00
rustdesk
2f28a96f6a scrap: keep enumerate_output6's both-or-neither promise when IDXGIOutput6 is missing
Review nit: a matched output whose IDXGIOutput6 query fails returned the
fresh factory next to a null output, which let the constructor pair a
current factory with the capturer's fallback output. Return neither in
that case so the next refresh enumerates again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 16:14:22 +08:00
rustdesk
2f3a6789de scrap: replace the DXGI factory and output together when re-reading HDR state
Review follow-up: when the fresh factory did not find the output by name,
the refresh kept the new, current factory next to the old output, and
because IsCurrent then reported TRUE it never enumerated again, so the
stale output could stay forever. enumerate_output6 now returns both or
neither, and the refresh only replaces the pair together; a null factory
forces another enumeration on the next refresh while the old output is
still read in the meantime. The constructor gets the same guarantee for
free, since a failed lookup leaves the factory null there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 16:00:47 +08:00
rustdesk
6e502cde2e scrap: re-read the Advanced Color state while capturing, gate FP16 on IDXGIOutput6
Review follow-up:

- The HDR/WCG decision was taken once when the conversion was created.
  HDR can be switched on or off, and a WCG desktop can turn into an HDR
  one, without the duplication being invalidated, so the choice between
  "divide by the SDR white level" and "no scaling" could go stale. The
  once-a-second refresh now re-reads it: it keeps an IDXGIFactory1,
  and when IsCurrent reports FALSE it creates a fresh factory and
  re-finds the output by GDI name, since a stale factory's outputs keep
  stale descriptions (this is the procedure the GetDesc1 docs require).
  On a change it switches modes, re-reads the white level and updates
  the shader constant; an unreadable level keeps the last known one.

- Float frames are only requested when IDXGIOutput6 exists, as
  Microsoft's duplication sample does. That interface is also what tells
  HDR from WCG, so there is no longer a state where FP16 is requested
  without being able to interpret it. IDXGIOutput6 dates from Windows 10
  1703, not 1803 as the previous commit said; 1803 added IDXGIFactory6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 15:44:23 +08:00
rustdesk
3730ab0733 scrap: only apply the SDR white level to outputs DXGI reports as HDR
Review follow-up:

- An FP16 desktop is not necessarily HDR. Since Windows 11 22H2 an
  Advanced Color (WCG) SDR display is composed in FP16 scRGB too, and
  there 1.0 is the display's reference white, not 80 nits, so the
  SDRWhiteLevel scaling does not apply (Microsoft: "Reference white
  applies only to HDR displays"). Query IDXGIOutput6::GetDesc1 and treat
  the output as HDR only for DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020,
  the documented Win32 check; a non-HDR FP16 frame now gets just the
  scRGB -> sRGB transfer. Without IDXGIOutput6 (before Windows 10 1803)
  FP16 can only mean HDR.

- The SDR white level is now Option: a failed query or a reported 0 is
  "unknown" on both the initial and the refresh path, an HDR output with
  an unknown level logs a warning and keeps the 80-nit assumption until
  the periodic re-query succeeds. DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL
  needs Windows 10 1709, not 1703 as an earlier message said.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 15:02:57 +08:00
rustdesk
696451b08b scrap: stay on DXGI when the HDR conversion fails, load the compiler once
Review follow-up:

- A tone-map failure surfaced as a capture error, and the capture loop
  answers any DXGI error by switching the capturer to GDI for the rest
  of its life. The capturer now drops the tone-map, re-creates the
  duplication with the legacy DuplicateOutput (DXGI's clipped BGRA8,
  the pre-HDR behaviour) and returns WouldBlock, so the session stays
  on DXGI and simply fetches the next frame.

- Only failures that cannot succeed anywhere in the process (no
  d3dcompiler_47.dll, shaders that do not compile) set the global
  UNAVAILABLE flag; D3D object creation failures stay with the capturer
  that hit them, so another adapter or a recreated capturer tries again.

- D3DCompile is resolved once through a OnceLock instead of a
  LoadLibrary per capturer that was never freed.

- The module doc now states what this pass is: normalization of an HDR
  desktop to SDR, with everything above SDR white clipping, not a tone
  map, and why a roll-off is deliberately not applied. The earlier
  claim that clipping matches what the local user sees was wrong for
  HDR content on an HDR display.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 13:04:23 +08:00
rustdesk
a7df138fc7 scrap: tone-map HDR desktops to SDR in the Windows capturer
With HDR enabled, Windows composes the desktop as linear scRGB in
R16G16B16A16_FLOAT and places SDR white at the user's "SDR content
brightness" (DISPLAYCONFIG_SDR_WHITE_LEVEL / 1000, e.g. 2.5 for 200
nits) rather than at 1.0. The legacy IDXGIOutput1::DuplicateOutput we
used always converts that surface to BGRA8, and it does so by clipping,
so everything above ~40% linear brightness lands on white. That is the
washed-out / overexposed / high-contrast picture reported for HDR hosts
in #5368, #9951, #12707 and discussion #7652.

Ask for the desktop with IDXGIOutput5::DuplicateOutput1 and the format
list [R16G16B16A16_FLOAT, B8G8R8A8_UNORM]. An SDR desktop still yields
BGRA8 and takes the unchanged path. A float frame is converted on the
GPU by a small pixel shader: divide by the SDR white level, clamp, apply
the sRGB transfer. SDR content round-trips exactly, HDR highlights clip
at white, the same result the local user sees. The white level is read
per output through DisplayConfigGetDeviceInfo and refreshed once a
second, so dragging the Windows slider tracks remotely. The converted
BGRA8 texture feeds both the staging-copy (CPU) path and the vram
(hwcodec texture) path; rotation is unchanged. Toggling HDR mid-session
invalidates the duplication as before, and the recreated capturer
re-detects the format.

The shaders are compiled at runtime from a dynamically loaded
d3dcompiler_47.dll so no new import is added to the binary. If the DLL,
the compile or any D3D object creation fails, a process-wide flag makes
later capturers fall back to the legacy DuplicateOutput, i.e. today's
behaviour.

This deliberately stops at SDR on the controlled side, with no option
and no protocol change, which is how OBS, Sunshine (client without HDR)
and macOS screen capture handle it. Real HDR pass-through would need
10-bit capture, Main10/AV1-10 encoders in the hwcodec fork, colour
metadata on the wire and, decisively, an HDR output path on the
controller: Flutter's desktop external textures are BGRA8888/RGBA8888
only on every platform, so nothing would be visible. When that changes
it should follow the Sunshine/Moonlight pattern: an hdr capability bit
advertised by the controller behind an explicit user toggle, negotiated
like i444.

Type-checked against x86_64-pc-windows-msvc (with and without the vram
feature); not yet run on an HDR machine, which needs Windows 10 1703+
with HDR on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
(cherry picked from commit ca3c2f8ac7d1549a40855411b0539a69c82d7223)
2026-09-02 12:36:48 +08:00
27 changed files with 993 additions and 2571 deletions

View File

@@ -43,7 +43,6 @@ env:
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
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
VERSION: "1.5.0"
NDK_VERSION: "r28c"
@@ -390,54 +389,6 @@ jobs:
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Build pre-built MSI template
# Two things this works around: preprocess.py rewrites res/msi in place, so the
# tree is reset around this second variant; and it locates the app as
# <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
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
shell: bash
@@ -974,33 +925,15 @@ jobs:
name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-aarch64
path: ./windows-aarch64/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86
path: ./windows-x86/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-x86_64
path: ./msi-template/
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-msi-template-aarch64
path: ./msi-template/
- name: Combine unsigned app
run: |
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
- name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@@ -1572,15 +1505,6 @@ jobs:
name: bridge-artifact
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
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11

View File

@@ -12,7 +12,7 @@ build = "build.rs"
brotli = "3.4"
dirs = "5.0"
md5 = "0.7"
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
winapi = { version = "0.3", features = ["winbase"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [

View File

@@ -15,29 +15,15 @@ encoding = 'utf-8'
# output: {path: (compressed_data, file_md5)}
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:
def generate_md5_table(folder: str, level) -> dict:
res: dict = dict()
skip = normalize(exclude) if exclude else None
excluded = False
# os.curdir is the literal ".", so restoring it left us inside `folder`.
curdir = os.getcwd()
curdir = os.curdir
os.chdir(folder)
for root, _, files in os.walk('.'):
# remove ./
for f in files:
md5_generator = md5()
full_path = os.path.join(root, f)
if skip and normalize(full_path) == skip:
print(f"Excluding {full_path}...")
excluded = True
continue
print(f"Processing {full_path}...")
f = open(full_path, "rb")
content = f.read()
@@ -47,16 +33,11 @@ def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
res[full_path] = (content_compressed, md5_code)
os.chdir(curdir)
if skip and not excluded:
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
return res
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
def write_blob(md5_table: dict, output_path: str, exe: str):
output_path = os.path.join(output_folder, "data.bin")
with open(output_path, "wb") as f:
f.write("rustdesk".encode(encoding=encoding))
for path in md5_table.keys():
@@ -111,14 +92,6 @@ if __name__ == '__main__':
help="the target used by cargo")
parser.add_option("-l", "--level", dest="level", type="int",
help="compression level, default is 11, highest", default=11)
parser.add_option("--package", dest="package",
help="write the per-customer blob to this path instead of "
"data.bin, and skip the cargo build. Injected into the "
"template's RDPKG resource so customizing needs no rebuild")
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
default=False,
help="omit the executable from the blob, for a template whose "
"executable ships in the package instead")
(options, args) = parser.parse_args()
folder = options.folder or './rustdesk'
output_folder = os.path.abspath(options.output_folder or './')
@@ -127,29 +100,14 @@ if __name__ == '__main__':
options.executable = 'rustdesk.exe'
if not options.executable.startswith(folder):
options.executable = folder + '/' + options.executable
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
# will result the print "Executable path: ..exe".
# So we need to check if the executable is in the folder, and if so, concat again.
if os.path.exists(os.path.join(folder, options.executable)):
options.executable = os.path.join(folder, options.executable)
folder_path = os.path.abspath(folder)
exe: str = os.path.abspath(options.executable)
try:
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
except ValueError:
in_source_folder = False
if not in_source_folder:
if not exe.startswith(os.path.abspath(folder)):
print("The executable must locate in source folder")
exit(-1)
exe = '.' + exe[len(folder_path):]
exe = '.' + exe[len(os.path.abspath(folder)):]
print("Executable path: " + exe)
print("Compression level: " + str(options.level))
md5_table = generate_md5_table(
folder, options.level, exe if options.exclude_exe else None)
if options.package:
write_blob(md5_table, os.path.abspath(options.package), exe)
else:
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)
md5_table = generate_md5_table(folder, options.level)
write_package_metadata(md5_table, output_folder, exe)
write_app_metadata(output_folder)
build_portable(output_folder, options.target)

View File

@@ -1,22 +1,15 @@
use std::{
collections::HashSet,
fs::{self},
io::{Cursor, Read},
path::Path,
};
// The generic payload, shared by every customer and compiled in once per release.
#[cfg(windows)]
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
// 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";
#[cfg(not(windows))]
const BIN_DATA: &[u8] = &[];
// 4bytes
const LENGTH: usize = 4;
const IDENTIFIER: &[u8] = b"rustdesk";
const IDENTIFIER_LENGTH: usize = 8;
const MD5_LENGTH: usize = 32;
const BUF_SIZE: usize = 4096;
@@ -31,172 +24,12 @@ pub(crate) struct BinaryData {
pub(crate) struct BinaryReader {
pub files: Vec<BinaryData>,
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 BinaryReader {
pub fn new() -> Result<Self, String> {
let package = read_package()?;
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
let (files, exe) = merge(read_embedded()?, package);
Ok(Self {
files,
exe,
package_paths,
})
}
}
// Folds the per-customer package into the generic payload.
fn merge(
embedded: (Vec<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))
impl Default for BinaryReader {
fn default() -> Self {
let (files, exe) = BinaryReader::read();
Self { files, exe }
}
}
@@ -235,6 +68,59 @@ impl BinaryData {
}
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)]
pub fn configure_permission(&self, prefix: &Path) {
use std::os::unix::prelude::PermissionsExt;
@@ -251,155 +137,3 @@ 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());
}
}

View File

@@ -5,7 +5,7 @@ use std::{
process::{Command, Stdio},
};
use bin_reader::{normalize_path, BinaryReader};
use bin_reader::BinaryReader;
pub mod bin_reader;
#[cfg(windows)]
@@ -17,24 +17,11 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
const APP_METADATA: &[u8] = &[];
const APP_METADATA_CONFIG: &str = "meta.toml";
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
const META_LINE_PREFIX_FILE: &str = "file = ";
const APP_PREFIX: &str = "rustdesk";
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
#[cfg(windows)]
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
// The extraction directory follows whatever executable the payload asks for, so a
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
fn app_dir_name(exe: &str) -> String {
Path::new(&exe.replace('\\', "/"))
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.trim().to_lowercase())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| APP_PREFIX.to_owned())
}
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
return true;
@@ -63,93 +50,13 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
false
}
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
fn write_meta(dir: &Path, ts: u64) {
let meta_file = dir.join(APP_METADATA_CONFIG);
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
for path in package_paths {
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
if ts != 0 {
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
// Ignore is ok here
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(
@@ -164,7 +71,7 @@ fn setup(
} else {
// home dir
if let Some(dir) = dirs::data_local_dir() {
dir.join(app_dir_name(&reader.exe))
dir.join(APP_PREFIX)
} else {
eprintln!("not found data local dir");
return None;
@@ -180,12 +87,10 @@ fn setup(
}
std::fs::remove_dir_all(&dir).ok();
}
let mut metadata_paths = reader.package_paths.clone();
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
for file in reader.files.iter() {
file.write_to_file(&dir);
}
write_meta(&dir, ts, &metadata_paths);
write_meta(&dir, ts);
#[cfg(windows)]
win::copy_runtime_broker(&dir);
#[cfg(linux)]
@@ -269,7 +174,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
}
}
fn main() -> Result<(), String> {
fn main() {
let mut args = Vec::new();
let mut arg_exe = Default::default();
let mut i = 0;
@@ -288,7 +193,7 @@ fn main() -> Result<(), String> {
let quick_support = false;
let mut ui = false;
let reader = BinaryReader::new()?;
let reader = BinaryReader::default();
if let Some(exe) = setup(
reader,
None,
@@ -303,7 +208,6 @@ fn main() -> Result<(), String> {
}
execute(exe, args, ui);
}
Ok(())
}
#[cfg(windows)]
@@ -342,27 +246,3 @@ mod win {
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
}
}
#[cfg(test)]
mod meta_tests {
use super::*;
#[test]
fn resolve_within_rejects_paths_that_escape() {
let base = Path::new("/base");
assert_eq!(
resolve_within(base, "./data/logo.png"),
Some(base.join("data").join("logo.png"))
);
assert_eq!(
resolve_within(base, ".\\data\\logo.png"),
Some(base.join("data").join("logo.png"))
);
// meta.toml is user-writable, so these must not reach remove_file.
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
assert_eq!(resolve_within(base, "/etc/passwd"), None);
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
assert_eq!(resolve_within(base, "."), None);
assert_eq!(resolve_within(base, ""), None);
}
}

View File

@@ -37,7 +37,7 @@ serde = {version="1.0", features=["derive"]}
[dependencies.winapi]
version = "0.3"
default-features = true
features = ["dxgi", "dxgi1_2", "dxgi1_5", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
features = ["dxgi", "dxgi1_2", "dxgi1_5", "dxgi1_6", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1"

View File

@@ -132,15 +132,7 @@ impl Display {
.map(Display)
.collect::<Vec<_>>();
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);
let displays_dxgi = Self::all_().unwrap_or(Default::default());
// Return gdi displays if dxgi is not supported
if displays_dxgi.is_empty() {
@@ -163,6 +155,7 @@ impl Display {
}
// Reorder displays from dxgi
let mut displays_dxgi = displays_dxgi;
let mut displays_dxgi_ordered = Vec::new();
for name in names_gdi.iter() {
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
@@ -183,11 +176,11 @@ impl Display {
}
pub fn width(&self) -> usize {
self.0.width().max(0) as usize
self.0.width() as usize
}
pub fn height(&self) -> usize {
self.0.height().max(0) as usize
self.0.height() as usize
}
pub fn name(&self) -> String {
@@ -208,8 +201,7 @@ impl Display {
pub fn is_primary(&self) -> bool {
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
// Detached outputs can still report origin (0,0) with a zero size.
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
self.origin() == (0, 0)
}
#[cfg(feature = "vram")]

629
libs/scrap/src/dxgi/hdr.rs Normal file
View File

@@ -0,0 +1,629 @@
//! HDR desktop -> SDR normalization for Desktop Duplication frames.
//!
//! With HDR enabled Windows composes the desktop as linear scRGB in
//! R16G16B16A16_FLOAT, and SDR "white" sits at the user's SDR content
//! brightness (DISPLAYCONFIG_SDR_WHITE_LEVEL) rather than at 1.0. The legacy
//! DuplicateOutput converts that to BGRA8 by clipping, which is the washed-out
//! picture reported for HDR hosts. This pass divides by the SDR white level,
//! clamps, and applies the sRGB transfer, so SDR content comes out exactly as
//! it would from an SDR desktop.
//!
//! It is a normalization, not a tone map: anything brighter than SDR white
//! (HDR video, HDR games) clips to white on the SDR viewer, where the local
//! HDR display would show it brighter than white. A roll-off would have to
//! move SDR white below 1.0 to make headroom, trading the accuracy of the SDR
//! content this pass exists for, so it is deliberately not done.
//!
//! Windows 11 22H2 also composes Advanced Color SDR (WCG) desktops in FP16,
//! but there 1.0 is the display's reference white rather than 80 nits and no
//! SDR white level applies. IDXGIOutput6 tells the two apart, and for a
//! non-HDR output the pass only applies the scRGB -> sRGB transfer.
//!
//! The conversion is automatic and stays on the controlled side on purpose:
//! the controller renders through Flutter external textures, which are 8-bit
//! on every desktop platform, so there is nothing to gain from sending HDR.
//! Real HDR pass-through, if the renderer ever supports it, should follow the
//! Sunshine/Moonlight pattern instead: an `hdr` capability bit advertised by
//! the controller behind an explicit user toggle, negotiated like i444.
use super::ComPtr;
use hbb_common::log;
use std::{
io, mem, ptr,
sync::{atomic::AtomicBool, OnceLock},
time::{Duration, Instant},
};
use winapi::{
ctypes::c_void,
shared::{
basetsd::SIZE_T,
dxgi::{CreateDXGIFactory1, IDXGIFactory1, IID_IDXGIFactory1, DXGI_OUTPUT_DESC},
dxgi1_2::IDXGIOutput1,
dxgi1_6::{IDXGIOutput6, IID_IDXGIOutput6, DXGI_OUTPUT_DESC1},
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
dxgitype::{DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_SAMPLE_DESC},
minwindef::{FALSE, LPCVOID, UINT, ULONG},
ntdef::{LONG, LPCSTR, WCHAR},
winerror::S_OK,
},
um::{
d3d11::*,
d3dcommon::{ID3DBlob, ID3DInclude, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SHADER_MACRO},
libloaderapi::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
unknwnbase::IUnknown,
wingdi::{
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
DISPLAYCONFIG_TOPOLOGY_ID,
},
winnt::HRESULT,
},
};
/// Set once the tone-map can never work in this process (no d3dcompiler, the
/// shaders do not compile). Capturers then stop asking DXGI for float frames.
/// Device-specific failures are not recorded here; the capturer that hit one
/// re-duplicates without the tone-map on its own.
pub static UNAVAILABLE: AtomicBool = AtomicBool::new(false);
/// Failures no capturer on this machine can recover from, as opposed to
/// device-specific ones that a recreated capturer may not hit again.
pub fn is_permanent(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::Unsupported
}
const VS_SRC: &str = "\
float4 main(uint id : SV_VertexID) : SV_Position {
float2 uv = float2((id << 1) & 2, id & 2);
return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
}";
const PS_SRC: &str = "\
Texture2D<float4> src : register(t0);
cbuffer Params : register(b0) { float inv_sdr_white; float3 pad; };
float4 main(float4 pos : SV_Position) : SV_Target {
float3 lin = saturate(src.Load(int3(pos.xy, 0)).rgb * inv_sdr_white);
float3 lo = lin * 12.92;
float3 hi = 1.055 * pow(lin, 1.0 / 2.4) - 0.055;
return float4(lerp(hi, lo, step(lin, 0.0031308)), 1.0);
}";
const OUTPUT_STATE_REFRESH: Duration = Duration::from_secs(1);
pub struct HdrToSdr {
device: ComPtr<ID3D11Device>,
context: ComPtr<ID3D11DeviceContext>,
vs: ComPtr<ID3D11VertexShader>,
ps: ComPtr<ID3D11PixelShader>,
params: ComPtr<ID3D11Buffer>,
target: ComPtr<ID3D11Texture2D>,
rtv: ComPtr<ID3D11RenderTargetView>,
srv: ComPtr<ID3D11ShaderResourceView>,
// Texture `srv` was created for. The view keeps it alive, so the address
// cannot be recycled behind our back.
srv_source: *mut ID3D11Texture2D,
width: u32,
height: u32,
device_name: [WCHAR; 32],
// Advanced Color state is read from `output6`, which is re-enumerated from a
// fresh factory whenever `factory` stops being current.
factory: ComPtr<IDXGIFactory1>,
output6: ComPtr<IDXGIOutput6>,
is_hdr: bool,
// DISPLAYCONFIG units (1000 == 80 nits == scRGB 1.0). `None` when it could
// not be read, in which case 80 nits is assumed until it can.
sdr_white_level: Option<u32>,
queried_at: Instant,
}
impl HdrToSdr {
pub fn new(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
output: *mut IDXGIOutput1,
device_name: &[WCHAR; 32],
) -> io::Result<Self> {
unsafe {
if device.is_null() || context.is_null() {
return Err(other("no d3d11 device"));
}
(*device).AddRef();
let device = ComPtr(device);
(*context).AddRef();
let context = ComPtr(context);
let compile = load_d3d_compile()?;
let vs_code = compile_shader(compile, VS_SRC, b"vs_4_0\0")?;
let ps_code = compile_shader(compile, PS_SRC, b"ps_4_0\0")?;
let mut vs = ptr::null_mut();
check(
(*device.0).CreateVertexShader(
(*vs_code.0).GetBufferPointer(),
(*vs_code.0).GetBufferSize(),
ptr::null_mut(),
&mut vs,
),
"CreateVertexShader",
)?;
let vs = ComPtr(vs);
let mut ps = ptr::null_mut();
check(
(*device.0).CreatePixelShader(
(*ps_code.0).GetBufferPointer(),
(*ps_code.0).GetBufferSize(),
ptr::null_mut(),
&mut ps,
),
"CreatePixelShader",
)?;
let ps = ComPtr(ps);
// Not found leaves the factory null, so the first refresh enumerates
// again instead of trusting the capturer's possibly stale output.
let (factory, mut output6) = enumerate_output6(device_name);
if output6.is_null() {
output6 = query_output6(output as *mut IUnknown);
}
// Float frames are only requested where IDXGIOutput6 exists, so an
// unreadable description still comes from an HDR-capable stack.
let is_hdr = output_is_hdr(output6.0).unwrap_or(true);
let sdr_white_level = if is_hdr {
query_sdr_white_level(device_name)
} else {
None
};
if is_hdr && sdr_white_level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read (needs Windows 10 1709+), \
assuming 80 nits until it can"
);
}
let init = params_data(sdr_white_level);
let desc = D3D11_BUFFER_DESC {
ByteWidth: mem::size_of_val(&init) as _,
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_CONSTANT_BUFFER,
CPUAccessFlags: 0,
MiscFlags: 0,
StructureByteStride: 0,
};
let data = D3D11_SUBRESOURCE_DATA {
pSysMem: init.as_ptr() as _,
SysMemPitch: 0,
SysMemSlicePitch: 0,
};
let mut params = ptr::null_mut();
check(
(*device.0).CreateBuffer(&desc, &data, &mut params),
"CreateBuffer",
)?;
let params = ComPtr(params);
log::info!(
"scRGB desktop conversion ready, hdr {is_hdr}, sdr white level {sdr_white_level:?}"
);
Ok(Self {
device,
context,
vs,
ps,
params,
target: ComPtr(ptr::null_mut()),
rtv: ComPtr(ptr::null_mut()),
srv: ComPtr(ptr::null_mut()),
srv_source: ptr::null_mut(),
width: 0,
height: 0,
device_name: *device_name,
factory,
output6,
is_hdr,
sdr_white_level,
queried_at: Instant::now(),
})
}
}
/// Renders `source` (R16G16B16A16_FLOAT) into an owned B8G8R8A8_UNORM
/// texture of the same size and returns it. The texture stays valid until
/// the next call.
pub fn convert(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
unsafe {
self.refresh_output_state();
self.ensure_target(desc.Width, desc.Height)?;
self.ensure_source_view(source)?;
let ctx = self.context.0;
let rtv = self.rtv.0;
let srv = self.srv.0;
let params = self.params.0;
let viewport = D3D11_VIEWPORT {
TopLeftX: 0.0,
TopLeftY: 0.0,
Width: self.width as f32,
Height: self.height as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
(*ctx).OMSetRenderTargets(1, &rtv, ptr::null_mut());
(*ctx).OMSetBlendState(ptr::null_mut(), &[0.0; 4], 0xffff_ffff);
(*ctx).OMSetDepthStencilState(ptr::null_mut(), 0);
(*ctx).RSSetState(ptr::null_mut());
(*ctx).RSSetViewports(1, &viewport);
(*ctx).IASetInputLayout(ptr::null_mut());
(*ctx).IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
(*ctx).VSSetShader(self.vs.0, ptr::null(), 0);
(*ctx).PSSetShader(self.ps.0, ptr::null(), 0);
(*ctx).PSSetConstantBuffers(0, 1, &params);
(*ctx).PSSetShaderResources(0, 1, &srv);
(*ctx).Draw(3, 0);
// Unbind so the next frame's copy and the encoder never see the
// target as a live render target or the desktop image as a bound
// shader input.
let no_srv: *mut ID3D11ShaderResourceView = ptr::null_mut();
(*ctx).PSSetShaderResources(0, 1, &no_srv);
(*ctx).OMSetRenderTargets(0, ptr::null(), ptr::null_mut());
Ok(self.target.0)
}
}
unsafe fn ensure_target(&mut self, width: u32, height: u32) -> io::Result<()> {
if !self.target.is_null() && self.width == width && self.height == height {
return Ok(());
}
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE,
CPUAccessFlags: 0,
MiscFlags: D3D11_RESOURCE_MISC_SHARED,
};
let mut target = ptr::null_mut();
check(
(*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut target),
"CreateTexture2D",
)?;
let target = ComPtr(target);
let mut rtv = ptr::null_mut();
check(
(*self.device.0).CreateRenderTargetView(target.0 as *mut _, ptr::null(), &mut rtv),
"CreateRenderTargetView",
)?;
self.rtv = ComPtr(rtv);
self.target = target;
self.width = width;
self.height = height;
Ok(())
}
unsafe fn ensure_source_view(&mut self, source: *mut ID3D11Texture2D) -> io::Result<()> {
if !self.srv.is_null() && self.srv_source == source {
return Ok(());
}
let mut srv = ptr::null_mut();
check(
(*self.device.0).CreateShaderResourceView(source as *mut _, ptr::null(), &mut srv),
"CreateShaderResourceView",
)?;
self.srv = ComPtr(srv);
self.srv_source = source;
Ok(())
}
// Advanced Color state is dynamic: HDR can be switched on or off, or a WCG
// desktop can turn into an HDR one, without the duplication being lost. An
// output's description is a snapshot, so once the factory is no longer
// current a new factory and output are needed to see the new state, as the
// GetDesc1 docs require.
unsafe fn refresh_output_state(&mut self) {
if self.queried_at.elapsed() < OUTPUT_STATE_REFRESH {
return;
}
self.queried_at = Instant::now();
if self.factory.is_null() || (*self.factory.0).IsCurrent() == FALSE {
let (factory, output6) = enumerate_output6(&self.device_name);
if !output6.is_null() {
self.factory = factory;
self.output6 = output6;
} else {
// Keep reading the old output, but enumerate again next time.
self.factory = ComPtr(ptr::null_mut());
}
}
let is_hdr = output_is_hdr(self.output6.0).unwrap_or(self.is_hdr);
let level = if is_hdr {
query_sdr_white_level(&self.device_name)
} else {
None
};
// A transiently unreadable level keeps the last known one.
if is_hdr == self.is_hdr && (level.is_none() || level == self.sdr_white_level) {
return;
}
log::info!(
"output changed: hdr {} -> {is_hdr}, sdr white level {:?} -> {level:?}",
self.is_hdr,
self.sdr_white_level
);
if is_hdr && level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read, assuming 80 nits until it can"
);
}
self.is_hdr = is_hdr;
self.sdr_white_level = level;
let data = params_data(level);
(*self.context.0).UpdateSubresource(
self.params.0 as *mut _,
0,
ptr::null(),
data.as_ptr() as _,
0,
0,
);
}
}
// `None` is either a non-HDR (WCG) output, where 1.0 already is the display's
// reference white, or an HDR output whose level is unknown; both use 1.0.
fn params_data(sdr_white_level: Option<u32>) -> [f32; 4] {
[
1000.0 / sdr_white_level.unwrap_or(1000) as f32,
0.0,
0.0,
0.0,
]
}
// FP16 desktop composition means either HDR (scene-referred, 1.0 == 80 nits)
// or, since Windows 11 22H2, Advanced Color SDR (display-referred), and only
// IDXGIOutput6 (Windows 10 1703) tells them apart. `None` when it cannot be
// read right now.
unsafe fn output_is_hdr(output6: *mut IDXGIOutput6) -> Option<bool> {
if output6.is_null() {
return None;
}
let mut desc: DXGI_OUTPUT_DESC1 = mem::zeroed();
if (*output6).GetDesc1(&mut desc) != S_OK {
return None;
}
Some(desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
}
unsafe fn query_output6(object: *mut IUnknown) -> ComPtr<IDXGIOutput6> {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
if !object.is_null() {
(*object).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
}
ComPtr(output6)
}
// A fresh factory sees the current display configuration; the output is found
// by its GDI name because the outputs of a stale factory keep stale descriptions.
// Returns both or neither: a non-null factory guarantees the output came from
// its topology, so a caller that sees a null factory knows to enumerate again.
unsafe fn enumerate_output6(
device_name: &[WCHAR; 32],
) -> (ComPtr<IDXGIFactory1>, ComPtr<IDXGIOutput6>) {
let mut factory: *mut c_void = ptr::null_mut();
if CreateDXGIFactory1(&IID_IDXGIFactory1, &mut factory) != S_OK {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
let factory = ComPtr(factory as *mut IDXGIFactory1);
let mut adapter_index = 0;
loop {
let mut adapter = ptr::null_mut();
if (*factory.0).EnumAdapters1(adapter_index, &mut adapter) != S_OK {
break;
}
let adapter = ComPtr(adapter);
adapter_index += 1;
let mut output_index = 0;
loop {
let mut output = ptr::null_mut();
if (*adapter.0).EnumOutputs(output_index, &mut output) != S_OK {
break;
}
let output = ComPtr(output);
output_index += 1;
let mut desc: DXGI_OUTPUT_DESC = mem::zeroed();
if (*output.0).GetDesc(&mut desc) == S_OK && wide_eq(&desc.DeviceName, device_name) {
let output6 = query_output6(output.0 as *mut IUnknown);
if output6.is_null() {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
return (factory, output6);
}
}
}
(ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()))
}
fn other(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::Other, msg.into())
}
fn check(hr: HRESULT, what: &str) -> io::Result<()> {
if hr == S_OK {
Ok(())
} else {
Err(other(format!("{what} failed: {hr:#x}")))
}
}
// D3DCompile(pSrcData, SrcDataSize, pSourceName, pDefines, pInclude,
// pEntrypoint, pTarget, Flags1, Flags2, ppCode, ppErrorMsgs)
type D3DCompileFn = unsafe extern "system" fn(
LPCVOID,
SIZE_T,
LPCSTR,
*const D3D_SHADER_MACRO,
*mut ID3DInclude,
LPCSTR,
LPCSTR,
UINT,
UINT,
*mut *mut ID3DBlob,
*mut *mut ID3DBlob,
) -> HRESULT;
static D3D_COMPILE: OnceLock<Result<D3DCompileFn, String>> = OnceLock::new();
// Loaded once per process and kept: the compiler DLL is only needed on HDR
// desktops, and an import-time link would make every install depend on it.
fn load_d3d_compile() -> io::Result<D3DCompileFn> {
D3D_COMPILE
.get_or_init(|| unsafe { find_d3d_compile() })
.clone()
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))
}
unsafe fn find_d3d_compile() -> Result<D3DCompileFn, String> {
let name: Vec<u16> = "d3dcompiler_47.dll\0".encode_utf16().collect();
let module = LoadLibraryExW(name.as_ptr(), ptr::null_mut(), LOAD_LIBRARY_SEARCH_SYSTEM32);
if module.is_null() {
return Err("d3dcompiler_47.dll not available".into());
}
let f = GetProcAddress(module, b"D3DCompile\0".as_ptr() as _);
if f.is_null() {
return Err("D3DCompile not exported".into());
}
Ok(mem::transmute::<_, D3DCompileFn>(f))
}
unsafe fn compile_shader(
compile: D3DCompileFn,
src: &str,
target: &[u8],
) -> io::Result<ComPtr<ID3DBlob>> {
let mut code = ptr::null_mut();
let mut errors = ptr::null_mut();
let hr = compile(
src.as_ptr() as _,
src.len(),
ptr::null(),
ptr::null(),
ptr::null_mut(),
b"main\0".as_ptr() as _,
target.as_ptr() as _,
0,
0,
&mut code,
&mut errors,
);
let errors = ComPtr(errors);
if hr != S_OK || code.is_null() {
let msg = if errors.is_null() {
String::new()
} else {
let bytes = std::slice::from_raw_parts(
(*errors.0).GetBufferPointer() as *const u8,
(*errors.0).GetBufferSize(),
);
String::from_utf8_lossy(bytes).into_owned()
};
if !code.is_null() {
(*(code as *mut IUnknown)).Release();
}
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("D3DCompile failed: {hr:#x} {msg}"),
));
}
Ok(ComPtr(code))
}
const DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL: u32 = 11;
const QDC_ONLY_ACTIVE_PATHS: u32 = 2;
#[repr(C)]
#[allow(non_snake_case)]
struct DISPLAYCONFIG_SDR_WHITE_LEVEL {
header: DISPLAYCONFIG_DEVICE_INFO_HEADER,
SDRWhiteLevel: ULONG,
}
#[link(name = "user32")]
extern "system" {
fn GetDisplayConfigBufferSizes(
flags: u32,
numPathArrayElements: *mut u32,
numModeInfoArrayElements: *mut u32,
) -> LONG;
fn QueryDisplayConfig(
flags: u32,
numPathArrayElements: *mut u32,
pathArray: *mut DISPLAYCONFIG_PATH_INFO,
numModeInfoArrayElements: *mut u32,
modeInfoArray: *mut DISPLAYCONFIG_MODE_INFO,
currentTopologyId: *mut DISPLAYCONFIG_TOPOLOGY_ID,
) -> LONG;
fn DisplayConfigGetDeviceInfo(requestPacket: *mut DISPLAYCONFIG_DEVICE_INFO_HEADER) -> LONG;
}
/// SDR white level of the output whose GDI name is `device_name`
/// (e.g. `\\.\DISPLAY1`), in DISPLAYCONFIG units (1000 == 80 nits). `None`
/// when the query fails (before Windows 10 1709) or reports 0.
fn query_sdr_white_level(device_name: &[WCHAR; 32]) -> Option<u32> {
unsafe {
let mut n_paths = 0u32;
let mut n_modes = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut n_paths, &mut n_modes) != 0 {
return None;
}
let mut paths: Vec<DISPLAYCONFIG_PATH_INFO> = vec![mem::zeroed(); n_paths as usize];
let mut modes: Vec<DISPLAYCONFIG_MODE_INFO> = vec![mem::zeroed(); n_modes as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut n_paths,
paths.as_mut_ptr(),
&mut n_modes,
modes.as_mut_ptr(),
ptr::null_mut(),
) != 0
{
return None;
}
for path in &paths[..n_paths as usize] {
let mut source: DISPLAYCONFIG_SOURCE_DEVICE_NAME = mem::zeroed();
source.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
source.header.size = mem::size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as _;
source.header.adapterId = path.sourceInfo.adapterId;
source.header.id = path.sourceInfo.id;
if DisplayConfigGetDeviceInfo(&mut source.header) != 0
|| !wide_eq(&source.viewGdiDeviceName, device_name)
{
continue;
}
let mut white: DISPLAYCONFIG_SDR_WHITE_LEVEL = mem::zeroed();
white.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL;
white.header.size = mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>() as _;
white.header.adapterId = path.targetInfo.adapterId;
white.header.id = path.targetInfo.id;
if DisplayConfigGetDeviceInfo(&mut white.header) == 0 && white.SDRWhiteLevel != 0 {
return Some(white.SDRWhiteLevel);
}
}
None
}
}
fn wide_eq(a: &[WCHAR], b: &[WCHAR]) -> bool {
let end = |s: &[WCHAR]| s.iter().position(|&c| c == 0).unwrap_or(s.len());
a[..end(a)] == b[..end(b)]
}

View File

@@ -1,18 +1,20 @@
use std::{io, mem, ptr, slice};
pub mod gdi;
pub use gdi::CapturerGDI;
pub mod hdr;
pub mod mag;
use winapi::{
shared::{
dxgi::*,
dxgi1_2::*,
dxgi1_6::*,
dxgiformat::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT},
dxgitype::*,
minwindef::{DWORD, FALSE, TRUE, UINT},
ntdef::LONG,
windef::{HMONITOR, RECT},
winerror::*,
// dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE},
},
um::{
d3d11::*, d3dcommon::D3D_DRIVER_TYPE_UNKNOWN, unknwnbase::IUnknown, wingdi::*,
@@ -58,6 +60,7 @@ pub struct Capturer {
output_texture: bool,
adapter_desc1: DXGI_ADAPTER_DESC1,
rotate: Rotate,
hdr: Option<hdr::HdrToSdr>,
}
impl Capturer {
@@ -105,7 +108,7 @@ impl Capturer {
}
} else {
res = wrap_hresult(unsafe {
let hres = (*display.inner.0).DuplicateOutput(device.0 as *mut _, &mut duplication);
let hres = Self::duplicate_output(&display, device.0, &mut duplication);
if hres != S_OK {
gdi_capturer = display.create_gdi();
println!("Fallback to GDI");
@@ -161,7 +164,8 @@ impl Capturer {
device,
context,
duplication: ComPtr(duplication),
fastlane: desc.DesktopImageInSystemMemory == TRUE,
fastlane: desc.DesktopImageInSystemMemory == TRUE
&& desc.ModeDesc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT,
surface: ComPtr(ptr::null_mut()),
texture: ComPtr(ptr::null_mut()),
width: display.width() as usize,
@@ -174,9 +178,102 @@ impl Capturer {
output_texture: false,
adapter_desc1,
rotate,
hdr: None,
})
}
// Asks for the float desktop that HDR mode composes so it can be tone-mapped;
// the legacy call would hand back DXGI's clipped BGRA8 conversion instead.
// Only where IDXGIOutput6 (Windows 10 1703) exists, since that is what later
// tells an HDR desktop from a WCG one; Microsoft's duplication sample gates
// the float request the same way.
unsafe fn duplicate_output(
display: &Display,
device: *mut ID3D11Device,
duplication: &mut *mut IDXGIOutputDuplication,
) -> HRESULT {
if !hdr::UNAVAILABLE.load(std::sync::atomic::Ordering::Relaxed) {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
(*display.inner.0).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
if !output6.is_null() {
let output6 = ComPtr(output6);
let formats = [DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM];
let hres = (*output6.0).DuplicateOutput1(
device as *mut _,
0,
formats.len() as UINT,
formats.as_ptr(),
duplication,
);
if hres == S_OK {
return hres;
}
hbb_common::log::warn!(
"HDR DuplicateOutput1 failed: hr={:#x}, fallback=DuplicateOutput",
hres as u32
);
}
}
(*display.inner.0).DuplicateOutput(device as *mut _, duplication)
}
unsafe fn tonemap(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
if self.hdr.is_none() {
match hdr::HdrToSdr::new(
self.device.0,
self.context.0,
self.display.inner.0,
&self.display.desc.DeviceName,
) {
Ok(hdr) => self.hdr = Some(hdr),
Err(err) => return self.abandon_tonemap(err),
}
}
let converted = match self.hdr.as_mut() {
Some(hdr) => hdr.convert(source, desc),
None => Err(io::Error::new(io::ErrorKind::Other, "no tone-map")),
};
match converted {
Ok(texture) => Ok(texture),
Err(err) => self.abandon_tonemap(err),
}
}
// Drops the tone-map and re-duplicates the output the legacy way, so DXGI
// hands over clipped BGRA8 (the pre-HDR behaviour). If re-duplication fails,
// switch to GDI before returning. The caller sees WouldBlock and asks again.
unsafe fn abandon_tonemap<T>(&mut self, err: io::Error) -> io::Result<T> {
if hdr::is_permanent(&err) {
hdr::UNAVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed);
}
hbb_common::log::error!("HDR tone-map failed, re-duplicating without it: {err}");
self.hdr = None;
(*self.duplication.0).ReleaseFrame();
self.duplication = ComPtr(ptr::null_mut());
let mut duplication = ptr::null_mut();
let result = wrap_hresult(
(*self.display.inner.0).DuplicateOutput(self.device.0 as *mut _, &mut duplication),
);
if let Err(err) = result {
if self.set_gdi() {
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(err);
}
self.duplication = ComPtr(duplication);
let mut desc: DXGI_OUTDUPL_DESC = mem::zeroed();
(*duplication).GetDesc(&mut desc);
self.fastlane = desc.DesktopImageInSystemMemory == TRUE;
Err(io::ErrorKind::WouldBlock.into())
}
fn create_rotations(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
@@ -330,6 +427,9 @@ impl Capturer {
}
unsafe fn load_frame(&mut self, timeout: UINT) -> io::Result<(*const u8, i32)> {
if self.duplication.0.is_null() {
return Err(io::ErrorKind::AddrNotAvailable.into());
}
let mut frame = ptr::null_mut();
#[allow(invalid_value)]
let mut info = mem::MaybeUninit::uninit().assume_init();
@@ -365,6 +465,12 @@ impl Capturer {
let mut texture_desc = mem::MaybeUninit::uninit().assume_init();
(*texture.0).GetDesc(&mut texture_desc);
let mut source = texture.0;
if texture_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
source = self.tonemap(texture.0, &texture_desc)?;
(*source).GetDesc(&mut texture_desc);
}
texture_desc.Usage = D3D11_USAGE_STAGING;
texture_desc.BindFlags = 0;
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
@@ -385,7 +491,7 @@ impl Capturer {
&mut surface as *mut *mut _ as *mut *mut _,
);
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
(*self.context.0).CopyResource(readable.0 as *mut _, source as *mut _);
Ok(surface)
}
@@ -492,6 +598,14 @@ impl Capturer {
let texture = ComPtr(texture);
self.texture = texture;
let mut frame_desc: D3D11_TEXTURE2D_DESC = mem::zeroed();
(*self.texture.0).GetDesc(&mut frame_desc);
if frame_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
let converted = self.tonemap(self.texture.0, &frame_desc)?;
(*converted).AddRef();
self.texture = ComPtr(converted);
}
let mut final_texture = self.texture.0 as *mut c_void;
let mut rotation = match self.display.rotation() {
DXGI_MODE_ROTATION_ROTATE90 => 90,
@@ -575,6 +689,9 @@ impl Capturer {
}
fn unmap(&self) {
if self.duplication.0.is_null() {
return;
}
unsafe {
(*self.duplication.0).ReleaseFrame();
if self.fastlane {

View File

@@ -297,30 +297,6 @@ pub fn clear_wayland_displays_cache() {
// 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)
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
let wayland_displays = get_displays();
@@ -356,8 +332,7 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
// Otherwise, we use the logical size for `uinput`.
if displays.len() == 1 {
let d = &displays[0];
let (w, h) = oriented_physical(d);
return Some((d.x, d.x + w, d.y, d.y + h));
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
}
let mut min_x = i32::MAX;
@@ -369,8 +344,6 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
min_y = min_y.min(d.y);
let size = if let Some(logical_size) = d.logical_size {
logical_size
} else if d.transform == 90 || d.transform == 270 {
oriented_physical(d)
} else {
// 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,
@@ -401,24 +374,6 @@ pub struct DisplayRect {
pub y: i32,
pub w: 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> {
@@ -431,9 +386,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
.iter()
.map(|d| {
let (w, h) = if single {
oriented_physical(d)
(d.width, d.height)
} else {
d.logical_size.unwrap_or_else(|| oriented_physical(d))
d.logical_size.unwrap_or((d.width, d.height))
};
DisplayRect {
name: d.name.clone(),
@@ -441,7 +396,6 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
y: d.y,
w,
h,
transform: d.transform,
}
})
.collect()
@@ -541,8 +495,8 @@ mod tests {
#[test]
fn test_clear_keeps_the_failure_stamp() {
// 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. The generation test also
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
// and dropping the stamp with it would defeat the backoff. Sole test touching these
// statics; serialize before adding another.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
@@ -565,7 +519,6 @@ mod tests {
height,
logical_size,
refresh_rate: 60,
transform: 0,
}
}
@@ -600,42 +553,6 @@ mod tests {
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 {
DisplayRect {
name: name.to_owned(),
@@ -643,7 +560,6 @@ mod tests {
y,
w,
h,
transform: 0,
}
}

View File

@@ -1,417 +0,0 @@
#!/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()

View File

@@ -1,292 +0,0 @@
#!/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()

View File

@@ -18,9 +18,6 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
namespace RemotePrinter
{
// `appName` names the printer and its port. It is passed in rather than compiled
// in so that a single dll serves every custom client; an empty value keeps the
// stock "RustDesk Printer" name.
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
VOID uninstallPrinter(const std::wstring& appName);
VOID installUpdatePrinter(const std::wstring& installFolder);
VOID uninstallPrinter();
}

View File

@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
{
do
{
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
{
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
if (process != NULL)
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
DWORD er = ERROR_SUCCESS;
int nResult = 0;
LPWSTR installFolder = NULL;
LPWSTR pwz = NULL;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
std::wstring installFolderValue;
hr = WcaInitialize(hInstall, "InstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
@@ -1031,27 +1031,12 @@ UINT __stdcall InstallPrinter(
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
// "<app name>|<install folder>". Split here rather than through
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
// Formatted property value cannot carry.
{
std::wstring data(pwzData);
size_t separator = data.find(L'|');
if (separator == std::wstring::npos)
{
// A package built before the name was passed in; keep the stock name.
appNameValue.clear();
installFolderValue = data;
}
else
{
appNameValue = data.substr(0, separator);
installFolderValue = data.substr(separator + 1);
}
}
pwz = pwzData;
hr = WcaReadStringFromCaData(&pwz, &installFolder);
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
RemotePrinter::installUpdatePrinter(installFolder);
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
LExit:
@@ -1069,30 +1054,14 @@ UINT __stdcall UninstallPrinter(
HRESULT hr = S_OK;
DWORD er = ERROR_SUCCESS;
LPWSTR pwzData = NULL;
std::wstring appNameValue;
hr = WcaInitialize(hInstall, "UninstallPrinter");
ExitOnFailure(hr, "Failed to initialize");
// Must match the name install used, otherwise the printer is left behind. Absent
// on packages built before this was passed in, where it was the stock name.
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
if (pwzData)
{
appNameValue = pwzData;
}
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
RemotePrinter::uninstallPrinter(appNameValue);
RemotePrinter::uninstallPrinter();
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
LExit:
if (pwzData) {
ReleaseStr(pwzData);
}
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}

View File

@@ -18,19 +18,12 @@ namespace RemotePrinter
{
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
// The driver files and the driver name ship with the app under their stock names
// and stay fixed for every custom client. Only the printer and its port carry the
// app name, and that arrives at runtime so one dll serves every custom client.
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
static std::wstring printerNameOf(const std::wstring &appName)
{
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
}
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
template <typename T, typename R>
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
@@ -465,12 +458,8 @@ namespace RemotePrinter
// We should not check the driver version because the driver is deployed with the application.
// It's better to uninstall the existing driver and install the driver from the application.
// 3. Add the printer.
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
VOID installUpdatePrinter(const std::wstring &installFolder)
{
const std::wstring printerName = printerNameOf(appName);
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
if (!FileExists(infFile))
{
@@ -516,15 +505,13 @@ namespace RemotePrinter
}
}
VOID uninstallPrinter(const std::wstring &appName)
VOID uninstallPrinter()
{
const std::wstring printerName = printerNameOf(appName);
deletePrinter(printerName.c_str());
deletePrinter(RD_PRINTER_NAME);
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
uninstallDriver(RD_PRINTER_DRIVER_NAME);
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
checkDeleteLocalPort(printerName.c_str());
checkDeleteLocalPort(RD_PRINTER_PORT);
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
}
}

View File

@@ -30,14 +30,7 @@
<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="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" 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]" />
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
<InstallExecuteSequence>
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
@@ -93,7 +86,6 @@
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>

View File

@@ -13,12 +13,6 @@
<PropertyRef Id="AddRemovePropertiesFile" />
<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" />
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />

View File

@@ -10,6 +10,7 @@ import subprocess
import re
import platform
from pathlib import Path
from itertools import chain
import shutil
from xml.sax.saxutils import quoteattr
@@ -66,14 +67,6 @@ def make_parser():
parser.add_argument(
"-c", "--custom", action="store_true", help="Is custom client", default=False
)
parser.add_argument(
"--template",
action="store_true",
default=False,
help="Build a template to be patched per customer rather than a finished "
"package: puts the files a custom client replaces in their own cabinet, so "
"rebranding rebuilds a few hundred KB instead of the whole payload.",
)
parser.add_argument(
"--conn-type",
type=str,
@@ -99,43 +92,6 @@ def make_parser():
return parser
# Files a custom client replaces. Kept in their own cabinet by --template so that
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
# The app executable is handled separately: it has its own component in RustDesk.wxs.
#
# A template has to ship a placeholder for each of these so there is a File row to
# patch, but the branding assets are optional for a customer and a stock build has
# none of them at all. So each optional one installs only when its property is set,
# which the patcher does for the files a customer actually supplied. Otherwise a
# customer without a logo would install the placeholder, where today they get no
# logo at all -- the client treats a missing asset as "no logo".
PER_CUSTOMER_DISK_ID = 2
PER_CUSTOMER_FILES = {
# relative path -> property gating installation, or None if always installed
"custom.txt": None,
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
}
def normalize_relative(relative_path):
path = relative_path.replace("\\", "/")
while path.startswith("./"):
path = path[2:]
return path.lower()
def is_per_customer(relative_path):
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
def per_customer_condition(relative_path):
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
def read_lines_and_start_index(file_path, tag_start, tag_end):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
@@ -156,7 +112,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
return lines, index_start
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
indent = g_indent_unit * 3
path = Path(dist_dir)
idx = 1
@@ -170,23 +126,12 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir, templ
if subdir != ".":
dir_attr = f'Subdirectory="{subdir}"'
relative = file_path.relative_to(path).as_posix()
disk_attr = ""
condition_attr = ""
if template and is_per_customer(relative):
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
# Branding assets are optional, and the template only carries a
# placeholder, so install one only when the customer supplied it.
condition = per_customer_condition(relative)
if condition:
condition_attr = f' Condition="{condition} = 1"'
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
# because it will cause error
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
to_insert_lines = f"""
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
{indent}</Component>
"""
lines.insert(index_start + 1, to_insert_lines[1:])
@@ -195,52 +140,17 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir, templ
return True
def gen_auto_component(app_name, dist_dir, template=False):
def gen_auto_component(app_name, dist_dir):
return gen_content_between_tags(
"Package/Components/RustDesk.wxs",
"<!--$AutoComonentStart$-->",
"<!--$AutoComponentEnd$-->",
lambda lines, index_start: insert_components_between_tags(
lines, index_start, app_name, dist_dir, template
lines, index_start, app_name, dist_dir
),
)
def gen_media2():
"""Second cabinet holding only what a custom client replaces."""
def func(lines, index_start):
indent = g_indent_unit * 2
lines.insert(
index_start + 1,
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
' EmbedCab="yes" CompressionLevel="high" />\n',
)
return lines
return gen_content_between_tags(
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
)
def put_app_exe_on_media2():
"""The app executable has its own component, so it is moved by name."""
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
with open(target, "r", encoding="utf-8") as f:
content = f.read()
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
new = (
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
)
if content.count(old) != 1:
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
return False
with open(target, "w", encoding="utf-8") as f:
f.write(content.replace(old, new))
return True
def gen_pre_vars(args, dist_dir):
def func(lines, index_start):
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
@@ -280,6 +190,18 @@ def replace_app_name_in_langs(app_name):
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def replace_app_name_in_custom_actions(app_name):
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
for i, line in enumerate(lines):
line = re.sub(r"\bRustDesk\b", app_name, line)
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
lines[i] = line
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
def gen_upgrade_info():
def func(lines, index_start):
indent = g_indent_unit * 3
@@ -556,16 +478,11 @@ if __name__ == "__main__":
if not gen_conn_type(args):
sys.exit(-1)
if args.template:
if not gen_media2():
sys.exit(-1)
if not put_app_exe_on_media2():
sys.exit(-1)
if not gen_auto_component(app_name, dist_dir, args.template):
if not gen_auto_component(app_name, dist_dir):
sys.exit(-1)
if not gen_custom_dialog_bitmaps():
sys.exit(-1)
replace_app_name_in_langs(args.app_name)
replace_app_name_in_custom_actions(args.app_name)

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
("screenshot-action-tip", "请选择如何继续截屏。"),
("Save as", "另存为"),
("Export", "导出"),
("Export Logs", "导出日志"),
("Import Folder", "导入文件夹"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "复制到剪贴板"),
("Enable remote printer", "启用远程打印机"),
("Downloading {}", "正在下载 {}"),

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."),
("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."),
("Save as", "Opslaan als"),
("Export", "Exporteren"),
("Export Logs", "Logboeken exporteren"),
("Import Folder", "Map importeren"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Kopiëren naar het klembord"),
("Enable remote printer", "Printer op afstand inschakelen"),
("Downloading {}", "Downloaden {}"),

View File

@@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."),
("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."),
("Save as", "Salvar como"),
("Export", "Exportar"),
("Export Logs", "Exportar logs"),
("Import Folder", "Importar pasta"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "Copiar para área de transferência"),
("Enable remote printer", "Habilitar impressora remota"),
("Downloading {}", "Baixando {}"),

View File

@@ -187,7 +187,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Enter your password", "輸入您的密碼"),
("Logging in...", "正在登入..."),
("Enable RDP session sharing", "啟用 RDP 工作階段分享"),
("Auto Login", "自動登入只在您設定「工作階段結束後鎖定」時有效"),
("Auto Login", "自動登入 (只在您設定「工作階段結束後鎖定」時有效)"),
("Enable direct IP access", "啟用 IP 直接存取"),
("Rename", "重新命名"),
("Space", "空白"),
@@ -300,7 +300,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Language", "語言"),
("Keep RustDesk background service", "保持 RustDesk 後台服務"),
("Ignore Battery Optimizations", "忽略電池最佳化"),
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
("android_open_battery_optimizations_tip", "如果您想要停用此功能,請前往下一個 RustDesk 應用程式設定頁面,找到並進入「電池」,取消勾選「不受限制」"),
("Start on boot", "開機時啟動"),
("Start the screen sharing service on boot, requires special permissions", "開機時啟動螢幕分享服務,需要特殊權限。"),
("Connection not allowed", "不允許連線"),
@@ -519,11 +519,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("I Agree", "同意"),
("Decline", "拒絕"),
("Timeout in minutes", "超時(分鐘)"),
("auto_disconnect_option_tip", "自動關閉不活躍的連入工作階段"),
("auto_disconnect_option_tip", "自動在連入的使用者不活躍時關閉工作階段"),
("Connection failed due to inactivity", "由於長時間沒有操作,已自動關閉工作階段"),
("Check for software update on startup", "啟動時檢查更新"),
("upgrade_rustdesk_server_pro_to_{}_tip", "請升級專業版伺服器到{}或更高版本!"),
("pull_group_failed_tip", "重新整理群組失敗"),
("pull_group_failed_tip", "獲取群組訊息失敗"),
("Filter by intersection", "按照交集篩選"),
("Remove wallpaper during incoming sessions", "在接受連入連線時移除桌布"),
("Test", "測試"),
@@ -639,7 +639,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Use D3D rendering", "使用 D3D 渲染"),
("Printer", "印表機"),
("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"),
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此裝置"),
("printer-requires-installed-{}-client-tip", "為了使用遠端列印功能,請安裝 {} 到此設備"),
("printer-{}-not-installed-tip", "{} 印表機未安裝。"),
("printer-{}-ready-tip", "{} 印表機已安裝,您可以使用列印功能了。"),
("Install {} Printer", "安裝 {} 印表機"),
@@ -659,17 +659,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"),
("screenshot-action-tip", "請選擇要如何處理這張截圖。"),
("Save as", "另存為"),
("Export", "匯出"),
("Export Logs", "匯出日誌"),
("Import Folder", "匯入資料夾"),
("Export", ""),
("Export Logs", ""),
("Import Folder", ""),
("Copy to clipboard", "複製到剪貼簿"),
("Enable remote printer", "啟用遠端列印"),
("Downloading {}", "正在下載 {} 並安裝新版本。"),
("{} Update", "{} 更新"),
("{}-to-update-tip", "即將關閉 {} 並安裝新版本。"),
("download-new-version-failed-tip", "下載失敗,您可以重試或點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("download-new-version-failed-tip", "下載失敗,您可以重試或點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("Auto update", "自動更新"),
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("update-failed-check-msi-tip", "安裝方式偵測失敗,請點\"下載\"按鈕以從發布網址下載,並手動升級。"),
("websocket_tip", "使用 WebSocket 時,只支援使用中繼連接。"),
("Use WebSocket", "使用 WebSocket"),
("Trackpad speed", "觸控板速度"),
@@ -680,7 +680,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("View camera", "檢視相機"),
("Enable camera", "允許查看鏡頭"),
("No cameras", "沒有鏡頭"),
("view_camera_unsupported_tip", "您的遠端裝置不支援查看鏡頭"),
("view_camera_unsupported_tip", "您的遠端設備不支援查看鏡頭"),
("Terminal", "終端機"),
("Enable terminal", "啟用終端機"),
("New tab", "新分頁"),
@@ -690,7 +690,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Failed to get user token.", "取得使用者權杖失敗"),
("Incorrect username or password.", "使用者名稱或密碼不正確"),
("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.", "僅支援於已安裝的版本"),
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
("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 on the minimized toolbar", "在最小化工具列上顯示"),
("All monitors", "所有顯示器"),
("#{} monitor", "{} 號顯示器"),
("conn-e2ee-unavailable-tip", "無法驗證端端加密。\n遠端裝置可能仍在準備中,請稍後試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
("#{} monitor", "{}號顯示器"),
("conn-e2ee-unavailable-tip", "無法驗證端端加密。\n遠端裝置可能仍在準備中,請稍後試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"),
("ID whitelisting", "ID 白名單"),
("Use ID whitelisting", "只允許白名單上的 ID 進行連線"),
("id_whitelist_tip", "只有白名單上的 ID 可以存取"),
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"),
("Invalid ID", "ID 無效"),
("Your ID is blocked by the peer", "的 ID 已被對方封鎖"),
("Your ip is blocked by the peer", "的 IP 已被對方封鎖"),
("id_whitelist_caveat_tip", "ID 由對端戶端回報。此白名單用於減少暴露面,不能取代密碼或 2FA"),
("Your ID is blocked by the peer", "的 ID 已被對方封鎖"),
("Your ip is blocked by the peer", "的 IP 已被對方封鎖"),
("id_whitelist_caveat_tip", "ID 由對端戶端回報白名單用於減少暴露面,不能取代密碼或 2FA"),
("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"),
("Continue", "繼續"),
("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"),

View File

@@ -585,9 +585,13 @@ impl Connection {
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
let mut last_recv_time = Instant::now();
// The connection type is not known until the login request arrives;
// `on_message` picks the type-specific timeout then.
conn.stream.set_send_timeout(SEND_TIMEOUT_VIDEO);
conn.stream.set_send_timeout(
if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() || conn.terminal {
SEND_TIMEOUT_OTHER
} else {
SEND_TIMEOUT_VIDEO
},
);
#[cfg(not(any(target_os = "android", target_os = "ios")))]
std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned));
@@ -2762,17 +2766,6 @@ 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()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)

View File

@@ -53,82 +53,6 @@ struct WaylandUinputRect {
struct WaylandLayout {
baseline: 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:
@@ -151,24 +75,9 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
#[cfg(target_os = "linux")]
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline);
}
/// 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);
let mut lock = WAYLAND_LAYOUT.lock().unwrap();
lock.baseline = baseline;
lock.live.clear();
}
// Remap an injected coordinate onto the live compositor layout when it has drifted from
@@ -191,6 +100,11 @@ fn refresh_wayland_uinput_rect_if_changed() {
if is_x11() || !crate::input_service::wayland_use_uinput() {
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();
if let Some(last_check) = lock.last_check {
@@ -206,55 +120,14 @@ fn refresh_wayland_uinput_rect_if_changed() {
// 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
// path needs the current per-display geometry to correct coordinates.
let (live_changed, mut drifted) = {
let drifted = {
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()
&& !live_rects.is_empty()
&& layout.baseline != live_rects;
layout.observe(&live_rects);
(live_changed, drifted)
layout.live = live_rects;
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 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.
@@ -848,177 +721,3 @@ mod tests {
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));
}
}

View File

@@ -52,17 +52,9 @@ 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 {
slot: Mutex<FrameSlot>,
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 {
@@ -71,14 +63,7 @@ pub struct IpcDrmCapturer {
display: i32,
connector: Option<String>,
// 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)>,
// 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_w: usize,
cur_h: usize,
@@ -91,102 +76,6 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
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.
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
match &*DRM_STATE.lock().unwrap() {
@@ -207,9 +96,6 @@ struct DisplayHealth {
/// 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.
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 {
@@ -221,7 +107,6 @@ impl DisplayHealth {
last_build: None,
rapid_builds: 0,
prefer_cpu: false,
fallback_rejected: false,
}
}
@@ -300,14 +185,6 @@ fn render_node_count() -> usize {
}
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);
impl IpcDrmCapturer {
@@ -316,7 +193,7 @@ impl IpcDrmCapturer {
pub fn new(
display: i32,
expected: Option<DrmDisplayInfo>,
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize, Option<(i32, i32)>)> {
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
let shared = Arc::new(Shared {
slot: Mutex::new(FrameSlot {
latest: None,
@@ -324,7 +201,6 @@ impl IpcDrmCapturer {
ended: None,
}),
cv: Condvar::new(),
transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING),
});
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
@@ -344,18 +220,6 @@ impl IpcDrmCapturer {
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((
IpcDrmCapturer {
shared,
@@ -364,9 +228,7 @@ impl IpcDrmCapturer {
connector: displays.get(wire_idx).map(connector_key),
session_size: displays
.get(wire_idx)
.map(|d| rotated_dims(transform, d.width as usize, d.height as usize)),
transform,
snapshot_gen,
.map(|d| (d.width as usize, d.height as usize)),
cur: Vec::new(),
cur_w: 0,
cur_h: 0,
@@ -375,7 +237,6 @@ impl IpcDrmCapturer {
},
displays,
wire_idx,
origin,
))
}
@@ -433,21 +294,10 @@ impl TraitCapturer for IpcDrmCapturer {
}
if let Some((w, h, fmt, buf)) = slot.latest.take() {
drop(slot);
// A layout change bumps the generation and is otherwise invisible here (mode
// and framebuffer keep their size). Rebuild for the new transform; not counted
// against health: the layout moved, the display did not fail.
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)) {
// convert_to_yuv only refuses a source LARGER than its destination, so a smaller
// frame leaves stale edges on screen. On the FIRST frame nothing changed: the list
// carries the CRTC mode, a frame the scanout fb, different when a CRTC scales.
if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) {
self.shared.slot.lock().unwrap().recycle(buf);
if !self.got_frame {
self.note_session_without_frame();
@@ -461,35 +311,15 @@ impl TraitCapturer for IpcDrmCapturer {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding",
"drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding",
self.display
),
));
}
if self.transform == 0 {
let previous = std::mem::replace(&mut self.cur, buf);
self.shared.slot.lock().unwrap().recycle(previous);
} 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;
let previous = std::mem::replace(&mut self.cur, buf);
self.shared.slot.lock().unwrap().recycle(previous);
self.cur_w = w;
self.cur_h = h;
self.cur_fmt = fmt;
if !self.got_frame {
// Clear ONLY the streak: `rapid_builds` is for a display that delivers a first
@@ -500,7 +330,6 @@ impl TraitCapturer for IpcDrmCapturer {
h.zero_frame_streak = 0;
h.demotes = 0;
h.since = Instant::now();
h.fallback_rejected = false;
}
}
}
@@ -631,21 +460,10 @@ async fn recv_thread(
}
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 {
if stop.load(Ordering::SeqCst) {
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 {
None => continue, // timeout: re-check stop at the loop top
Some(Ok(pair)) => pair,
@@ -762,23 +580,18 @@ async fn recv_thread(
raw.len()
);
}
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
if t == TRANSFORM_PENDING {
pending_cursor = Some((id, width, height, hotx, hoty, raw));
} else {
pending_cursor = None;
deliver_drm_cursor(
display,
cursor_epoch,
set_drm_cursor(
display,
cursor_epoch,
DrmCursorData {
id,
width,
height,
width: width as i32,
height: height as i32,
hotx,
hoty,
raw,
t,
);
}
colors: raw,
},
);
}
Ok(Err(err)) => break format!("cursor body: {err}"),
}
@@ -904,56 +717,6 @@ 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> {
let map = DRM_CURSOR.lock().unwrap();
map.values()
@@ -1420,22 +1183,12 @@ 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
// 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.
// offline; a single connector remains usable through the whole-desktop fallback.
fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) {
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
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;
}
let health = DRM_DISPLAY_HEALTH.lock().unwrap();
for (display, info) in list.iter().zip(infos.iter_mut()) {
if health
.get(&connector_key(display))
@@ -1446,21 +1199,6 @@ 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 {
assignment
.iter()
@@ -1528,36 +1266,18 @@ fn augment_with_wayland_geometry_from(
if origin_only && drm.len() > 1 {
return infos;
}
let identity = identity_matches(drm, &wl.displays);
for (i, info) in infos.iter_mut().enumerate() {
let Some(w) = matched[i].map(|j| &wl.displays[j]) else {
continue;
};
info.x = w.x;
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 {
continue;
}
if let Some((lw, lh)) = w.logical_size {
if lw > 0 && lh > 0 {
// 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.scale = drm[i].width as f64 / lw as f64;
info.original_resolution = super::display_service::get_original_resolution(
&drm[i].name,
lw as usize,
@@ -1572,62 +1292,18 @@ fn augment_with_wayland_geometry_from(
/// 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
/// DRM's (0,0).
/// 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(
fn assign_wayland_outputs(
drm: &[DrmDisplayInfo],
wl: &[hbb_common::platform::linux::WaylandDisplayInfo],
) -> Vec<Option<usize>> {
let mut taken = vec![false; wl.len()];
let mut matched: Vec<Option<usize>> = vec![None; drm.len()];
for (i, d) in drm.iter().enumerate() {
let dn = normalize_connector(&d.name);
if let Some((j, _)) = wl
.iter()
.enumerate()
.find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn)
{
if let Some(j) = match_wayland_display(d, wl, &taken) {
matched[i] = Some(j);
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() {
if matched[i].is_some() {
continue;
@@ -1653,6 +1329,30 @@ fn assign_wayland_outputs(
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").
/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2".
@@ -1713,13 +1413,11 @@ pub(super) fn get_capturer_info(
}
h.zero_frame_streak = 0;
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.
let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?;
let (capturer, displays, wire_idx) = 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.
if let Some(key) = key.clone() {
let now = Instant::now();
@@ -1747,14 +1445,16 @@ pub(super) fn get_capturer_info(
.get(wire_idx)
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
.clone();
// Origin and transform come from the ONE snapshot new() resolved, so both reflect the
// same output assignment; dimensions stay PHYSICAL, rotated to frame orientation.
let origin = origin.unwrap_or((d.x, d.y));
let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize);
// Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin
// matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer.
let origin = augment_with_wayland_geometry(&displays)
.get(wire_idx)
.map(|di| (di.x, di.y))
.unwrap_or((d.x, d.y));
Ok(super::video_service::CapturerInfo {
origin,
width: cap_w,
height: cap_h,
width: d.width as usize,
height: d.height as usize,
ndisplay,
current: display_idx,
privacy_mode_id: 0,
@@ -1782,14 +1482,11 @@ mod drm_capturer_tests {
ended: None,
}),
cv: Condvar::new(),
transform: std::sync::atomic::AtomicI32::new(0),
}),
stop: Arc::new(AtomicBool::new(false)),
display: 0,
connector,
session_size: session,
transform: 0,
snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(),
cur: Vec::new(),
cur_w: 0,
cur_h: 0,
@@ -1798,172 +1495,6 @@ 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 {
let key = c.connector.clone().expect("this check needs an identity");
DRM_DISPLAY_HEALTH
@@ -1994,7 +1525,6 @@ mod drm_capturer_tests {
h.rapid_builds = 3;
h.last_build = Some(Instant::now());
h.prefer_cpu = true;
h.fallback_rejected = true;
}
put_frame(&c, 64, 32);
assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_)));
@@ -2007,10 +1537,6 @@ mod drm_capturer_tests {
};
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!(
!h.fallback_rejected,
"a delivered frame also refutes the rejected-fallback verdict"
);
assert_eq!(
h.rapid_builds, 3,
"but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \
@@ -2116,53 +1642,9 @@ mod drm_capturer_tests {
height: h,
logical_size: Some((w, h)),
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]
fn one_connector_assignment_drives_geometry_and_primary() {
let drm = [
@@ -2243,32 +1725,6 @@ 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]
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)];

View File

@@ -108,8 +108,7 @@ struct CapDisplayInfo {
}
/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be
/// 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
/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so
/// unlike `desktop_rect_of` there is no logical size to handle.
#[cfg(feature = "drm")]
fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
@@ -522,13 +521,11 @@ pub(super) fn get_capturer_for_display(
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
// `try_fix_logical_size` only repairs the capturable's SEPARATE
// `logical_size` field and never touches `physical_size`, so the rect is not
// logical. The advertised DRM geometry is physical too, in DELIVERED
// orientation: `augment_with_wayland_geometry` transposes width/height for a
// 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated
// is UNMEASURED on a rotated display (pipewiresrc does not apply
// 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.
// logical. The advertised DRM geometry is physical too
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
// width/height as the DRM mode). Dividing one side by the scale therefore
// compares logical against physical and rejects the valid stream on exactly
// the scaled outputs it was meant to rescue.
//
// 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
@@ -540,35 +537,15 @@ pub(super) fn get_capturer_for_display(
// a monitor on a card the service cannot open is missing from the DRM list
// while the compositor still drives it.
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
&& advertised.y == rect.0 .1
&& (size_matches || (single_display && !transposed));
&& (single_display
|| (advertised.width as usize == rect.1
&& advertised.height as usize == rect.2));
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!(
"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,
if transposed {
" - stream is transposed vs advertised"
} else {
""
},
advertised.width,
advertised.height,
advertised.x,