mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-15 00:41:01 +03:00
Compare commits
9 Commits
0fd1a0eecb
...
hdr-tonema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce968c02dd | ||
|
|
89d444b950 | ||
|
|
792618e59a | ||
|
|
2f28a96f6a | ||
|
|
2f3a6789de | ||
|
|
6e502cde2e | ||
|
|
3730ab0733 | ||
|
|
696451b08b | ||
|
|
a7df138fc7 |
68
.github/workflows/flutter-build.yml
vendored
68
.github/workflows/flutter-build.yml
vendored
@@ -389,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
|
||||
@@ -973,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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
629
libs/scrap/src/dxgi/hdr.rs
Normal file
629
libs/scrap/src/dxgi/hdr.rs
Normal 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, ¶ms);
|
||||
(*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)]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 >= 603" />
|
||||
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}"),
|
||||
|
||||
@@ -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.", "瀏覽器未開啟?請使用下方網址登入。"),
|
||||
|
||||
Reference in New Issue
Block a user