mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-19 23:29:32 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dbe27ea57 | ||
|
|
a2725df7cd | ||
|
|
f32988b454 | ||
|
|
adf83a1b25 | ||
|
|
ea74ed12b8 | ||
|
|
5f3b980373 | ||
|
|
23e70c0fd1 | ||
|
|
4b14f86134 | ||
|
|
ee2478168c | ||
|
|
f4bbf82363 | ||
|
|
1cb53c1f7a | ||
|
|
eea9e0fa43 | ||
|
|
ac630c2ca6 | ||
|
|
9831f93430 | ||
|
|
c074a1d6af | ||
|
|
47c93f8544 | ||
|
|
c06ac9341a | ||
|
|
8d231b4605 | ||
|
|
745ba1673d | ||
|
|
2ef1dd99de | ||
|
|
960d9a042f | ||
|
|
10457dfe45 | ||
|
|
971d4e6976 | ||
|
|
2dbff45588 | ||
|
|
2bdb621417 | ||
|
|
47b00054d2 | ||
|
|
d1c8b331c5 | ||
|
|
1403c939db | ||
|
|
f1d2073d43 | ||
|
|
f7c930e153 | ||
|
|
22005bac75 | ||
|
|
8f7bb5a032 | ||
|
|
e0fd698101 | ||
|
|
b2cc9eac23 | ||
|
|
5f521c80a7 | ||
|
|
f0f999dc27 | ||
|
|
df4a101316 | ||
|
|
bdc53f0190 | ||
|
|
cef4175961 | ||
|
|
4ff75412c3 | ||
|
|
f1329ca69e | ||
|
|
c95aaf563e | ||
|
|
11fed81c4d | ||
|
|
561bc18f49 | ||
|
|
6946b863f7 | ||
|
|
8f68861920 | ||
|
|
171d178b09 | ||
|
|
7305b6bd1c | ||
|
|
6600c8c648 | ||
|
|
32b77f8968 | ||
|
|
0bda90f8fb | ||
|
|
2b68c46fdc | ||
|
|
bfbf00f18c | ||
|
|
e17ab74040 | ||
|
|
41cd375e3c | ||
|
|
0d919157c9 | ||
|
|
00293a9902 | ||
|
|
bc3a58f6f4 | ||
|
|
d8496aba0b | ||
|
|
280c12942f | ||
|
|
3a5b30a5e7 | ||
|
|
c46023bbde | ||
|
|
93feedc212 | ||
|
|
6f1a769741 |
36
.github/workflows/flutter-build.yml
vendored
36
.github/workflows/flutter-build.yml
vendored
@@ -33,7 +33,7 @@ env:
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2025.01.13
|
||||
VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
VERSION: "1.3.8"
|
||||
VERSION: "1.3.9"
|
||||
NDK_VERSION: "r27c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -159,14 +159,44 @@ jobs:
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
# Windows: build RustDesk
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
|
||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||
Expand-Archive usbmmidd_v2.zip -DestinationPath .
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
|
||||
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
mv -Force .\usbmmidd_v2 ./rustdesk
|
||||
|
||||
# Download printer driver files and extract them to ./rustdesk
|
||||
try {
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4.zip -OutFile rustdesk_printer_driver_v4.zip
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/sha256sums -OutFile sha256sums
|
||||
|
||||
# Check and move the files
|
||||
$checksum_driver = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*rustdesk_printer_driver_v4\.zip$').Matches.Groups[1].Value
|
||||
$downloadsum_driver = Get-FileHash -Path rustdesk_printer_driver_v4.zip -Algorithm SHA256
|
||||
$checksum_dll = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*printer_driver_adapter\.zip$').Matches.Groups[1].Value
|
||||
$downloadsum_dll = Get-FileHash -Path printer_driver_adapter.zip -Algorithm SHA256
|
||||
if ($checksum_driver -eq $downloadsum_driver.Hash -and $checksum_dll -eq $downloadsum_dll.Hash) {
|
||||
Write-Output "rustdesk_printer_driver_v4, checksums match, extract the file."
|
||||
Expand-Archive rustdesk_printer_driver_v4.zip -DestinationPath .
|
||||
mkdir ./rustdesk/drivers
|
||||
mv -Force .\rustdesk_printer_driver_v4 ./rustdesk/drivers/RustDeskPrinterDriver
|
||||
Expand-Archive printer_driver_adapter.zip -DestinationPath .
|
||||
mv -Force .\printer_driver_adapter.dll ./rustdesk
|
||||
} elseif ($checksum_driver -ne $downloadsum_driver.Hash) {
|
||||
Write-Output "rustdesk_printer_driver_v4, checksums do not match, ignore the file."
|
||||
} else {
|
||||
Write-Output "printer_driver_adapter.dll, checksums do not match, ignore the file."
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Ingore the printer driver error."
|
||||
}
|
||||
|
||||
- name: find Runner.res
|
||||
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
|
||||
# Runner.rc does not contain actual version, but Runner.res does
|
||||
|
||||
2
.github/workflows/playground.yml
vendored
2
.github/workflows/playground.yml
vendored
@@ -18,7 +18,7 @@ env:
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2024.11.16
|
||||
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c"
|
||||
VERSION: "1.3.8"
|
||||
VERSION: "1.3.9"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
|
||||
554
Cargo.lock
generated
554
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.3.8"
|
||||
version = "1.3.9"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -116,10 +116,12 @@ winapi = { version = "0.3", features = [
|
||||
"cguid",
|
||||
"cfgmgr32",
|
||||
"ioapiset",
|
||||
"winspool",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
virtual_display = { path = "libs/virtual_display" }
|
||||
remote_printer = { path = "libs/remote_printer" }
|
||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||
shared_memory = "0.12"
|
||||
tauri-winrt-notification = "0.1.2"
|
||||
@@ -177,7 +179,7 @@ jni = "0.21"
|
||||
android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
||||
|
||||
[workspace]
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable"]
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
|
||||
[package.metadata.winres]
|
||||
@@ -197,6 +199,7 @@ os-version = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
hound = "3.5"
|
||||
docopt = "1.1"
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustDesk"
|
||||
|
||||
11
README.md
11
README.md
@@ -8,6 +8,11 @@
|
||||
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
||||
</p>
|
||||
|
||||
> [!Caution]
|
||||
> **Misuse Disclaimer:** <br>
|
||||
> The developers of RustDesk do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized access, control or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application.
|
||||
|
||||
|
||||
Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
@@ -163,11 +168,7 @@ Please ensure that you are running these commands from the root of the RustDesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
|
||||
|
||||
> [!Caution]
|
||||
> **Misuse Disclaimer:** <br>
|
||||
> The developers of RustDesk do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized access, control or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application.
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.3.8
|
||||
version: 1.3.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.3.8
|
||||
version: 1.3.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
40
docs/CONTRIBUTING-KR.md
Normal file
40
docs/CONTRIBUTING-KR.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# RustDesk 기여 가이드라인
|
||||
|
||||
RustDesk는 모든 사람의 기여를 환영합니다. 만약 RustDesk에 기여하고 싶다면 아래 가이드를 참고해주세요:
|
||||
|
||||
## 기여 방식
|
||||
|
||||
RustDesk 또는 종속성에 대한 기여는 GitHub Pull Request 형태로 이루어져야 합니다.
|
||||
모든 Pull Request는 코어 기여자가 검토하며, 메인 저장소에 반영되거나 필요한 수정 사항에 대한 피드백을 받습니다.
|
||||
모든 기여는 이 형식을 따라야 합니다.
|
||||
|
||||
특정 이슈에 작업하고 싶다면, 먼저 GitHub 이슈에 댓글을 달아 작업하겠다고 알려주세요.
|
||||
이는 동일한 작업에 대해 중복 기여가 발생하는 것을 방지하기 위함입니다.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- master 브랜치에서 새 브랜치를 생성하고 작업하세요.<br/>
|
||||
필요한 경우 PR 제출 전에 최신 master 브랜치에 리베이스(rebase)하세요.<br/>
|
||||
충돌이 발생하면 기여자가 직접 해결해야 합니다.
|
||||
|
||||
- 커밋은 가능한 한 작고 독립적인 단위로 작성하세요.<br/>
|
||||
각 커밋은 독립적으로 빌드와 테스트를 통과해야 합니다.
|
||||
|
||||
- 커밋에는 반드시 Developer Certificate of Origin (http://developercertificate.org) 서명이 포함되어야 합니다.<br/>
|
||||
이는 기여자(및 소속된 고용주가 있을 경우) 가 [프로젝트 라이선스](../LICENCE) 에 동의함을 나타냅니다.<br/>
|
||||
Git에서는 `git commit` 명령어에 `-s` 옵션을 사용해 서명을 추가할 수 있습니다.
|
||||
|
||||
- PR이 검토되지 않거나 특정 리뷰어가 필요하면,
|
||||
<br/> PR이나 댓글에서 리뷰어를 태그하거나 [이메일](mailto:info@rustdesk.com)로 리뷰를 요청할 수 있습니다.
|
||||
|
||||
- 수정된 버그나 추가된 기능과 관련된 테스트 코드를 포함해주세요.
|
||||
|
||||
Git 사용에 대한 자세한 내용은 [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow)을 참조하세요.
|
||||
|
||||
## 행동 강령
|
||||
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## 커뮤니케이션
|
||||
|
||||
RustDesk 기여자들은 [Discord](https://discord.gg/nDceKgxnkV)에서 활동하고 있습니다.
|
||||
@@ -9,6 +9,11 @@
|
||||
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
||||
</p>
|
||||
|
||||
> [!Vorsicht]
|
||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||
|
||||
|
||||
Reden Sie mit uns auf: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
@@ -147,10 +152,6 @@ target/release/rustdesk
|
||||
|
||||
Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDesk-Repositorys nutzen. Ansonsten kann es passieren, dass das Programm die Ressourcen nicht finden kann. Bitte bedenken Sie auch, dass andere Cargo-Unterbefehle wie `install` oder `run` aktuell noch nicht unterstützt werden, da sie das Programm innerhalb des Containers starten oder installieren würden, anstatt auf Ihrem eigentlichen System.
|
||||
|
||||
> [!Vorsicht]
|
||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
||||
</p>
|
||||
|
||||
> [!警告]
|
||||
> **免责声明:** <br>
|
||||
> RustDesk 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。
|
||||
|
||||
与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
@@ -218,10 +222,6 @@ target/release/rustdesk
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
|
||||
|
||||
> [!警告]
|
||||
> **免责声明:** <br>
|
||||
> RustDesk 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。
|
||||
|
||||
## 截图
|
||||
|
||||

|
||||
|
||||
90
examples/ipc.rs
Normal file
90
examples/ipc.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use docopt::Docopt;
|
||||
use hbb_common::{
|
||||
env_logger::{init_from_env, Env, DEFAULT_FILTER_ENV},
|
||||
log, tokio,
|
||||
};
|
||||
use librustdesk::{ipc::Data, *};
|
||||
|
||||
const USAGE: &'static str = "
|
||||
IPC test program.
|
||||
|
||||
Usage:
|
||||
ipc (-s | --server | -c | --client) [-p <str> | --postfix=<str>]
|
||||
ipc (-h | --help)
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-s --server Run as IPC server.
|
||||
-c --client Run as IPC client.
|
||||
-p --postfix=<str> IPC path postfix [default: ].
|
||||
";
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Args {
|
||||
flag_server: bool,
|
||||
flag_client: bool,
|
||||
flag_postfix: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info"));
|
||||
|
||||
let args: Args = Docopt::new(USAGE)
|
||||
.and_then(|d| d.deserialize())
|
||||
.unwrap_or_else(|e| e.exit());
|
||||
|
||||
if args.flag_server {
|
||||
if args.flag_postfix.is_empty() {
|
||||
log::info!("Starting IPC server...");
|
||||
} else {
|
||||
log::info!(
|
||||
"Starting IPC server with postfix: '{}'...",
|
||||
args.flag_postfix
|
||||
);
|
||||
}
|
||||
ipc_server(&args.flag_postfix).await;
|
||||
} else if args.flag_client {
|
||||
if args.flag_postfix.is_empty() {
|
||||
log::info!("Starting IPC client...");
|
||||
} else {
|
||||
log::info!(
|
||||
"Starting IPC client with postfix: '{}'...",
|
||||
args.flag_postfix
|
||||
);
|
||||
}
|
||||
ipc_client(&args.flag_postfix).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn ipc_server(postfix: &str) {
|
||||
let postfix = postfix.to_string();
|
||||
let postfix2 = postfix.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = crate::ipc::start(&postfix) {
|
||||
log::error!("Failed to start ipc: {}", err);
|
||||
std::process::exit(-1);
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
ipc_client(&postfix2).await;
|
||||
}
|
||||
|
||||
async fn ipc_client(postfix: &str) {
|
||||
loop {
|
||||
match crate::ipc::connect(1000, postfix).await {
|
||||
Ok(mut conn) => match conn.send(&Data::Empty).await {
|
||||
Ok(_) => {
|
||||
log::info!("send message to ipc server success");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to send message to ipc server: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to ipc server: {}", e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx1024M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.daemon=false
|
||||
|
||||
BIN
flutter/assets/more.ttf
Normal file
BIN
flutter/assets/more.ttf
Normal file
Binary file not shown.
@@ -29,8 +29,10 @@ import '../consts.dart';
|
||||
import 'common/widgets/overlay.dart';
|
||||
import 'mobile/pages/file_manager_page.dart';
|
||||
import 'mobile/pages/remote_page.dart';
|
||||
import 'mobile/pages/view_camera_page.dart';
|
||||
import 'desktop/pages/remote_page.dart' as desktop_remote;
|
||||
import 'desktop/pages/file_manager_page.dart' as desktop_file_manager;
|
||||
import 'desktop/pages/view_camera_page.dart' as desktop_view_camera;
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'models/model.dart';
|
||||
import 'models/platform_model.dart';
|
||||
@@ -96,6 +98,7 @@ enum DesktopType {
|
||||
main,
|
||||
remote,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
cm,
|
||||
portForward,
|
||||
}
|
||||
@@ -105,6 +108,8 @@ class IconFont {
|
||||
static const _family2 = 'PeerSearchbar';
|
||||
static const _family3 = 'AddressBook';
|
||||
static const _family4 = 'DeviceGroup';
|
||||
static const _family5 = 'More';
|
||||
|
||||
IconFont._();
|
||||
|
||||
static const IconData max = IconData(0xe606, fontFamily: _family1);
|
||||
@@ -120,6 +125,7 @@ class IconFont {
|
||||
IconData(0xe623, fontFamily: _family4);
|
||||
static const IconData deviceGroupFill =
|
||||
IconData(0xe748, fontFamily: _family4);
|
||||
static const IconData more = IconData(0xe609, fontFamily: _family5);
|
||||
}
|
||||
|
||||
class ColorThemeExtension extends ThemeExtension<ColorThemeExtension> {
|
||||
@@ -1750,7 +1756,8 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + type.name, v: pos.toString());
|
||||
|
||||
if (type == WindowType.RemoteDesktop && windowId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
type, windowId, isMaximized, isFullscreen, pos);
|
||||
}
|
||||
@@ -1901,7 +1908,9 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
String? pos;
|
||||
// No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs)
|
||||
// Though "open in tabs" is true and the new window restore peer position, it's ok.
|
||||
if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null &&
|
||||
peerId != null) {
|
||||
final peerPos = bind.mainGetPeerFlutterOptionSync(
|
||||
id: peerId, k: windowFramePrefix + type.name);
|
||||
if (peerPos.isNotEmpty) {
|
||||
@@ -1916,7 +1925,7 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
debugPrint("no window position saved, ignoring position restoration");
|
||||
return false;
|
||||
}
|
||||
if (type == WindowType.RemoteDesktop) {
|
||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||
if (!isRemotePeerPos && windowId != null) {
|
||||
if (lpos.offsetWidth != null) {
|
||||
lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset;
|
||||
@@ -2085,6 +2094,7 @@ StreamSubscription? listenUniLinks({handleByFlutter = true}) {
|
||||
enum UriLinkType {
|
||||
remoteDesktop,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
rdp,
|
||||
}
|
||||
@@ -2136,6 +2146,11 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
type = UriLinkType.viewCamera;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--port-forward':
|
||||
type = UriLinkType.portForward;
|
||||
id = args[i + 1];
|
||||
@@ -2177,6 +2192,12 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.viewCamera:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newViewCamera(id!,
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.portForward:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newPortForward(id!, false,
|
||||
@@ -2200,7 +2221,14 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
String? command;
|
||||
String? id;
|
||||
final options = ["connect", "play", "file-transfer", "port-forward", "rdp"];
|
||||
final options = [
|
||||
"connect",
|
||||
"play",
|
||||
"file-transfer",
|
||||
"view-camera",
|
||||
"port-forward",
|
||||
"rdp"
|
||||
];
|
||||
if (uri.authority.isEmpty &&
|
||||
uri.path.split('').every((char) => char == '/')) {
|
||||
return [];
|
||||
@@ -2238,6 +2266,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
connect(Get.context!, id);
|
||||
} else if (optionIndex == 2) {
|
||||
connect(Get.context!, id, isFileTransfer: true);
|
||||
} else if (optionIndex == 3) {
|
||||
connect(Get.context!, id, isViewCamera: true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2290,6 +2320,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
|
||||
connectMainDesktop(String id,
|
||||
{required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isTcpTunneling,
|
||||
required bool isRDP,
|
||||
bool? forceRelay,
|
||||
@@ -2302,6 +2333,12 @@ connectMainDesktop(String id,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isViewCamera) {
|
||||
await rustDeskWinManager.newViewCamera(id,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isTcpTunneling || isRDP) {
|
||||
await rustDeskWinManager.newPortForward(id, isRDP,
|
||||
password: password,
|
||||
@@ -2318,10 +2355,12 @@ connectMainDesktop(String id,
|
||||
|
||||
/// Connect to a peer with [id].
|
||||
/// If [isFileTransfer], starts a session only for file transfer.
|
||||
/// If [isViewCamera], starts a session only for view camera.
|
||||
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
||||
/// If [isRDP], starts a session only for rdp.
|
||||
connect(BuildContext context, String id,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
bool forceRelay = false,
|
||||
@@ -2353,6 +2392,7 @@ connect(BuildContext context, String id,
|
||||
await connectMainDesktop(
|
||||
id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
password: password,
|
||||
@@ -2363,6 +2403,7 @@ connect(BuildContext context, String id,
|
||||
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
|
||||
'id': id,
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera,
|
||||
'isTcpTunneling': isTcpTunneling,
|
||||
'isRDP': isRDP,
|
||||
'password': password,
|
||||
@@ -2400,6 +2441,31 @@ connect(BuildContext context, String id,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (isViewCamera) {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) =>
|
||||
desktop_view_camera.ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
toolbarState: ToolbarState(),
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
isSharedPassword: isSharedPassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) => ViewCameraPage(
|
||||
id: id, password: password, isSharedPassword: isSharedPassword),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
@@ -2686,6 +2752,8 @@ String getWindowName({WindowType? overrideType}) {
|
||||
return name;
|
||||
case WindowType.FileTransfer:
|
||||
return "File Transfer - $name";
|
||||
case WindowType.ViewCamera:
|
||||
return "View Camera - $name";
|
||||
case WindowType.PortForward:
|
||||
return "Port Forward - $name";
|
||||
case WindowType.RemoteDesktop:
|
||||
@@ -3051,6 +3119,7 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
'peer_id': peerId,
|
||||
'display': i,
|
||||
'display_count': pi.displays.length,
|
||||
'window_type': (kWindowType ?? WindowType.RemoteDesktop).index,
|
||||
};
|
||||
if (screenRect != null) {
|
||||
args['screen_rect'] = {
|
||||
@@ -3065,12 +3134,12 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
}
|
||||
|
||||
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
|
||||
int? display, Rect? screenRect) async {
|
||||
WindowType windowType, int? display, Rect? screenRect) async {
|
||||
if (screenRect == null) {
|
||||
// Do not restore window position to new connection if there's a pre-session.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/8825
|
||||
if (preSessionCount == 0) {
|
||||
await restoreWindowPosition(WindowType.RemoteDesktop,
|
||||
await restoreWindowPosition(windowType,
|
||||
windowId: windowId, display: display, peerId: peerId);
|
||||
}
|
||||
} else {
|
||||
@@ -3720,3 +3789,29 @@ void updateTextAndPreserveSelection(
|
||||
baseOffset: 0, extentOffset: controller.value.text.length);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> getPrinterNames() {
|
||||
final printerNamesJson = bind.mainGetPrinterNames();
|
||||
if (printerNamesJson.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
final List<dynamic> printerNamesList = jsonDecode(printerNamesJson);
|
||||
final appPrinterName = '$appName Printer';
|
||||
return printerNamesList
|
||||
.map((e) => e.toString())
|
||||
.where((name) => name != appPrinterName)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('failed to parse printer names, err: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
String _appName = '';
|
||||
String get appName {
|
||||
if (_appName.isEmpty) {
|
||||
_appName = bind.mainGetAppNameSync();
|
||||
}
|
||||
return _appName;
|
||||
}
|
||||
|
||||
@@ -509,13 +509,13 @@ class _AddressBookState extends State<AddressBook> {
|
||||
|
||||
double marginBottom = 4;
|
||||
|
||||
row({required Widget lable, required Widget input}) {
|
||||
row({required Widget label, required Widget input}) {
|
||||
makeChild(bool isPortrait) => Row(
|
||||
children: [
|
||||
!isPortrait
|
||||
? ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
child: lable.marginOnly(right: 10))
|
||||
child: label.marginOnly(right: 10))
|
||||
: SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
@@ -535,7 +535,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
Column(
|
||||
children: [
|
||||
row(
|
||||
lable: Row(
|
||||
label: Row(
|
||||
children: [
|
||||
Text(
|
||||
'*',
|
||||
@@ -558,7 +558,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
errorMaxLines: 5),
|
||||
).workaroundFreezeLinuxMint())),
|
||||
row(
|
||||
lable: Text(
|
||||
label: Text(
|
||||
translate('Alias'),
|
||||
style: style,
|
||||
),
|
||||
@@ -573,7 +573,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
),
|
||||
if (isCurrentAbShared)
|
||||
row(
|
||||
lable: Text(
|
||||
label: Text(
|
||||
translate('Password'),
|
||||
style: style,
|
||||
),
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:convert';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
@@ -412,24 +411,38 @@ class DialogTextField extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: title,
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
helperText: helperText,
|
||||
helperMaxLines: 8,
|
||||
errorText: errorText,
|
||||
errorMaxLines: 8,
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLength: maxLength,
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: title,
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
helperText: helperText,
|
||||
helperMaxLines: 8,
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLength: maxLength,
|
||||
),
|
||||
if (errorText != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectableText(
|
||||
errorText!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
).paddingOnly(top: 8, left: 12),
|
||||
),
|
||||
],
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -488,6 +488,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String title, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
}) {
|
||||
@@ -502,6 +503,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
peer,
|
||||
tab,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
);
|
||||
@@ -530,6 +532,15 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _viewCameraAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
context,
|
||||
translate('View camera'),
|
||||
isViewCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _tcpTunnelingAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
@@ -880,6 +891,7 @@ class RecentPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -939,6 +951,7 @@ class FavoritePeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -992,6 +1005,7 @@ class DiscoveredPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -1045,6 +1059,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1177,6 +1192,7 @@ class MyGroupPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1398,6 +1414,7 @@ class TagPainter extends CustomPainter {
|
||||
|
||||
void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false}) async {
|
||||
var password = '';
|
||||
@@ -1423,6 +1440,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -53,13 +54,14 @@ class RawKeyFocusScope extends StatelessWidget {
|
||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FFI ffi;
|
||||
|
||||
final bool isCamera;
|
||||
late final InputModel inputModel = ffi.inputModel;
|
||||
late final FfiModel ffiModel = ffi.ffiModel;
|
||||
|
||||
RawTouchGestureDetectorRegion({
|
||||
required this.child,
|
||||
required this.ffi,
|
||||
this.isCamera = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -382,6 +384,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
_scale = d.scale;
|
||||
|
||||
if (scale != 0) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -402,6 +405,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -536,3 +540,46 @@ class RawPointerMouseRegion extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawPointerMouseRegion extends StatelessWidget {
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final PointerUpEventListener? onPointerUp;
|
||||
|
||||
CameraRawPointerMouseRegion({
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
this.onPointerDown,
|
||||
this.onPointerUp,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerHover: (evt) {
|
||||
final offset = evt.position;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
|
||||
},
|
||||
onPointerDown: (evt) {
|
||||
onPointerDown?.call(evt);
|
||||
},
|
||||
onPointerUp: (evt) {
|
||||
onPointerUp?.call(evt);
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: MouseCursor.defer,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
List<TTextMenu> v = [];
|
||||
// elevation
|
||||
if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) {
|
||||
if (isDefaultConn &&
|
||||
perms['keyboard'] != false &&
|
||||
ffi.elevationModel.showRequestMenu) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Request Elevation')),
|
||||
@@ -101,7 +104,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// osAccount / osPassword
|
||||
if (perms['keyboard'] != false) {
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
@@ -130,7 +133,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// paste
|
||||
if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
|
||||
if (isDefaultConn &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
perms['keyboard'] != false) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
@@ -142,43 +147,53 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// reset canvas
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Reset canvas')),
|
||||
onPressed: () => ffi.cursorModel.reset()));
|
||||
}
|
||||
|
||||
connectWithToken(
|
||||
{required bool isFileTransfer, required bool isTcpTunneling}) {
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId);
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
connToken: connToken);
|
||||
}
|
||||
|
||||
// transferFile
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Transfer file')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: true, isTcpTunneling: false)),
|
||||
onPressed: () => connectWithToken(isFileTransfer: true)),
|
||||
);
|
||||
}
|
||||
// viewCamera
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('View camera')),
|
||||
onPressed: () => connectWithToken(isViewCamera: true)),
|
||||
);
|
||||
}
|
||||
// tcpTunneling
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('TCP tunneling')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: false, isTcpTunneling: true)),
|
||||
onPressed: () => connectWithToken(isTcpTunneling: true)),
|
||||
);
|
||||
}
|
||||
// note
|
||||
if (bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
if (isDefaultConn &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Note')),
|
||||
@@ -186,11 +201,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// divider
|
||||
if (isDesktop || isWebDesktop) {
|
||||
if (isDefaultConn && (isDesktop || isWebDesktop)) {
|
||||
v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true));
|
||||
}
|
||||
// ctrlAltDel
|
||||
if (!ffiModel.viewOnly &&
|
||||
if (isDefaultConn &&
|
||||
!ffiModel.viewOnly &&
|
||||
ffiModel.keyboard &&
|
||||
(pi.platform == kPeerPlatformLinux || pi.sasEnabled)) {
|
||||
v.add(
|
||||
@@ -200,7 +216,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// restart
|
||||
if (perms['restart'] != false &&
|
||||
if (isDefaultConn &&
|
||||
perms['restart'] != false &&
|
||||
(pi.platform == kPeerPlatformLinux ||
|
||||
pi.platform == kPeerPlatformWindows ||
|
||||
pi.platform == kPeerPlatformMacOS)) {
|
||||
@@ -212,7 +229,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// insertLock
|
||||
if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
if (isDefaultConn && !ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Insert Lock')),
|
||||
@@ -220,7 +237,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// blockUserInput
|
||||
if (ffi.ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffi.ffiModel.keyboard &&
|
||||
ffi.ffiModel.permissions['block_input'] != false &&
|
||||
pi.platform == kPeerPlatformWindows) // privacy-mode != true ??
|
||||
{
|
||||
@@ -236,12 +254,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// switchSides
|
||||
if (isDesktop &&
|
||||
if (isDefaultConn &&
|
||||
isDesktop &&
|
||||
ffiModel.keyboard &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
pi.platform != kPeerPlatformMacOS &&
|
||||
versionCmp(pi.version, '1.2.0') >= 0 &&
|
||||
bind.peerGetDefaultSessionsCount(id: id) == 1) {
|
||||
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Switch Sides')),
|
||||
onPressed: () =>
|
||||
@@ -523,6 +542,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
// show quality monitor
|
||||
final option = 'show-quality-monitor';
|
||||
@@ -535,7 +555,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
},
|
||||
child: Text(translate('Show quality monitor'))));
|
||||
// mute
|
||||
if (perms['audio'] != false) {
|
||||
if (isDefaultConn && perms['audio'] != false) {
|
||||
final option = 'disable-audio';
|
||||
final value =
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
@@ -556,7 +576,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 &&
|
||||
bind.mainHasFileClipboard() &&
|
||||
pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard);
|
||||
if (ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
perms['file'] != false &&
|
||||
(isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
@@ -574,7 +595,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Enable file copy and paste'))));
|
||||
}
|
||||
// disable clipboard
|
||||
if (ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
if (isDefaultConn && ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'disable-clipboard';
|
||||
var value =
|
||||
@@ -591,7 +612,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Disable clipboard'))));
|
||||
}
|
||||
// lock after session end
|
||||
if (ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
if (isDefaultConn && ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'lock-after-session-end';
|
||||
final value =
|
||||
@@ -656,12 +677,12 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('True color (4:4:4)'))));
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.addAll(toolbarKeyboardToggles(ffi));
|
||||
}
|
||||
|
||||
// view mode (mobile only, desktop is in keyboard menu)
|
||||
if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
if (isDefaultConn && isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
v.add(TToggleMenu(
|
||||
value: ffiModel.viewOnly,
|
||||
onChanged: (value) async {
|
||||
|
||||
@@ -27,6 +27,7 @@ const String kPlatformAdditionsAmyuniVirtualDisplays =
|
||||
const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
const String kPlatformAdditionsSupportViewCamera = "support_view_camera";
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
@@ -44,6 +45,7 @@ const String kAppTypeConnectionManager = "cm";
|
||||
|
||||
const String kAppTypeDesktopRemote = "remote";
|
||||
const String kAppTypeDesktopFileTransfer = "file transfer";
|
||||
const String kAppTypeDesktopViewCamera = "view camera";
|
||||
const String kAppTypeDesktopPortForward = "port forward";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
@@ -58,6 +60,7 @@ const String kWindowConnect = "connect";
|
||||
|
||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||
const String kWindowEventNewViewCamera = "new_view_camera";
|
||||
const String kWindowEventNewPortForward = "new_port_forward";
|
||||
const String kWindowEventActiveSession = "active_session";
|
||||
const String kWindowEventActiveDisplaySession = "active_display_session";
|
||||
@@ -75,6 +78,7 @@ const String kOptionScrollStyle = "scroll_style";
|
||||
const String kOptionImageQuality = "image_quality";
|
||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||
const String kOptionTextureRender = "use-texture-render";
|
||||
const String kOptionD3DRender = "allow-d3d-render";
|
||||
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
||||
const String kOptionOpenInWindows = "allow-open-in-windows";
|
||||
const String kOptionForceAlwaysRelay = "force-always-relay";
|
||||
@@ -94,9 +98,11 @@ const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
// "Settings -> Security -> Permissions"
|
||||
const String kOptionEnableRemotePrinter = "enable-remote-printer";
|
||||
const String kOptionEnableClipboard = "enable-clipboard";
|
||||
const String kOptionEnableFileTransfer = "enable-file-transfer";
|
||||
const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
@@ -214,6 +220,14 @@ const double kDefaultQuality = 50;
|
||||
const double kMaxQuality = 100;
|
||||
const double kMaxMoreQuality = 2000;
|
||||
|
||||
const String kKeyPrinterIncommingJobAction = 'printer-incomming-job-action';
|
||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||
const String kValuePrinterIncomingJobDefault = '';
|
||||
const String kValuePrinterIncomingJobSelected = 'selected';
|
||||
const String kKeyPrinterSelected = 'printer-selected-name';
|
||||
const String kKeyPrinterSave = 'allow-printer-dialog-save';
|
||||
const String kKeyPrinterAllowAutoPrint = 'allow-printer-auto-print';
|
||||
|
||||
double kNewWindowOffset = isWindows
|
||||
? 56.0
|
||||
: isLinux
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -17,7 +19,7 @@ import '../../common/formatter/id_formatter.dart';
|
||||
import '../../common/widgets/peer_tab_page.dart';
|
||||
import '../../common/widgets/autocomplete.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/button.dart';
|
||||
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
|
||||
class OnlineStatusWidget extends StatefulWidget {
|
||||
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
|
||||
@@ -203,6 +205,8 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
final FocusNode _idFocusNode = FocusNode();
|
||||
final TextEditingController _idEditingController = TextEditingController();
|
||||
|
||||
String selectedConnectionType = 'Connect';
|
||||
|
||||
bool isWindowMinimized = false;
|
||||
|
||||
final AllPeersLoader _allPeersLoader = AllPeersLoader();
|
||||
@@ -210,6 +214,8 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
// https://github.com/flutter/flutter/issues/157244
|
||||
Iterable<Peer> _autocompleteOpts = [];
|
||||
|
||||
final _menuOpen = false.obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -321,9 +327,10 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
|
||||
/// Callback for the connect button.
|
||||
/// Connects to the selected peer.
|
||||
void onConnect({bool isFileTransfer = false}) {
|
||||
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id, isFileTransfer: isFileTransfer);
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -501,21 +508,87 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 13.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Button(
|
||||
isOutline: true,
|
||||
onTap: () => onConnect(isFileTransfer: true),
|
||||
text: "Transfer file",
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
SizedBox(
|
||||
height: 28.0,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
onConnect();
|
||||
},
|
||||
child: Text(translate("Connect")),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 17,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 28.0,
|
||||
width: 28.0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
Button(onTap: onConnect, text: "Connect"),
|
||||
],
|
||||
),
|
||||
)
|
||||
child: Center(
|
||||
child: Obx(() {
|
||||
var offset = Offset(0, 0);
|
||||
return InkWell(
|
||||
child: _menuOpen.value
|
||||
? Transform.rotate(
|
||||
angle: pi,
|
||||
child: Icon(IconFont.more, size: 14),
|
||||
)
|
||||
: Icon(IconFont.more, size: 14),
|
||||
onTapDown: (e) {
|
||||
offset = e.globalPosition;
|
||||
},
|
||||
onTap: () async {
|
||||
_menuOpen.value = true;
|
||||
final x = offset.dx;
|
||||
final y = offset.dy;
|
||||
await mod_menu
|
||||
.showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: [
|
||||
(
|
||||
'Transfer file',
|
||||
() => onConnect(isFileTransfer: true)
|
||||
),
|
||||
(
|
||||
'View camera',
|
||||
() => onConnect(isViewCamera: true)
|
||||
),
|
||||
]
|
||||
.map((e) => MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate(e.$1),
|
||||
style: style,
|
||||
),
|
||||
proc: () => e.$2(),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: kDesktopMenuPadding.left),
|
||||
dismissOnClicked: true,
|
||||
))
|
||||
.map((e) => e.build(
|
||||
context,
|
||||
const MenuConfig(
|
||||
commonColor:
|
||||
CustomPopupMenuTheme.commonColor,
|
||||
height: CustomPopupMenuTheme.height,
|
||||
dividerHeight: CustomPopupMenuTheme
|
||||
.dividerHeight)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
elevation: 8,
|
||||
)
|
||||
.then((_) {
|
||||
_menuOpen.value = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -134,12 +134,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _leftPaneScrollController,
|
||||
child: Column(
|
||||
key: _childKey,
|
||||
children: children,
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _leftPaneScrollController,
|
||||
child: Column(
|
||||
key: _childKey,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
Expanded(child: Container())
|
||||
],
|
||||
),
|
||||
if (isOutgoingOnly)
|
||||
Positioned(
|
||||
@@ -770,6 +775,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
await connectMainDesktop(
|
||||
call.arguments['id'],
|
||||
isFileTransfer: call.arguments['isFileTransfer'],
|
||||
isViewCamera: call.arguments['isViewCamera'],
|
||||
isTcpTunneling: call.arguments['isTcpTunneling'],
|
||||
isRDP: call.arguments['isRDP'],
|
||||
password: call.arguments['password'],
|
||||
@@ -784,9 +790,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window id '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null) {
|
||||
WindowType? windowType;
|
||||
try {
|
||||
windowType = WindowType.values.byName(args[3]);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window type '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null && windowType != null) {
|
||||
await rustDeskWinManager.moveTabToNewWindow(
|
||||
windowId, args[1], args[2]);
|
||||
windowId, args[1], args[2], windowType);
|
||||
}
|
||||
} else if (call.method == kWindowEventOpenMonitorSession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
@@ -794,9 +806,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
final peerId = args['peer_id'] as String;
|
||||
final display = args['display'] as int;
|
||||
final displayCount = args['display_count'] as int;
|
||||
final windowType = args['window_type'] as int;
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
await rustDeskWinManager.openMonitorSession(
|
||||
windowId, peerId, display, displayCount, screenRect);
|
||||
windowId, peerId, display, displayCount, screenRect, windowType);
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final windowId = int.tryParse(call.arguments);
|
||||
if (windowId != null) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
@@ -55,6 +56,7 @@ enum SettingsTabKey {
|
||||
display,
|
||||
plugin,
|
||||
account,
|
||||
printer,
|
||||
about,
|
||||
}
|
||||
|
||||
@@ -74,6 +76,7 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
||||
SettingsTabKey.plugin,
|
||||
if (!bind.isDisableAccount()) SettingsTabKey.account,
|
||||
if (isWindows) SettingsTabKey.printer,
|
||||
SettingsTabKey.about,
|
||||
];
|
||||
|
||||
@@ -198,6 +201,10 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
settingTabs.add(
|
||||
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
|
||||
break;
|
||||
case SettingsTabKey.printer:
|
||||
settingTabs
|
||||
.add(_TabInfo(tab, 'Printer', Icons.print_outlined, Icons.print));
|
||||
break;
|
||||
case SettingsTabKey.about:
|
||||
settingTabs
|
||||
.add(_TabInfo(tab, 'About', Icons.info_outline, Icons.info));
|
||||
@@ -229,6 +236,9 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
case SettingsTabKey.account:
|
||||
children.add(const _Account());
|
||||
break;
|
||||
case SettingsTabKey.printer:
|
||||
children.add(const _Printer());
|
||||
break;
|
||||
case SettingsTabKey.about:
|
||||
children.add(const _About());
|
||||
break;
|
||||
@@ -496,6 +506,16 @@ class _GeneralState extends State<_General> {
|
||||
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
|
||||
),
|
||||
),
|
||||
if (isWindows)
|
||||
Tooltip(
|
||||
message: translate('d3d_render_tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
"Use D3D rendering",
|
||||
kOptionD3DRender,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
@@ -953,6 +973,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
_OptionCheckBox(
|
||||
context, 'Enable keyboard/mouse', kOptionEnableKeyboard,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (isWindows)
|
||||
_OptionCheckBox(
|
||||
context, 'Enable remote printer', kOptionEnableRemotePrinter,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable clipboard', kOptionEnableClipboard,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(
|
||||
@@ -960,6 +984,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable audio', kOptionEnableAudio,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(
|
||||
context, 'Enable TCP tunneling', kOptionEnableTunnel,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
@@ -1869,6 +1895,153 @@ class _PluginState extends State<_Plugin> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Printer extends StatefulWidget {
|
||||
const _Printer({super.key});
|
||||
|
||||
@override
|
||||
State<_Printer> createState() => __PrinterState();
|
||||
}
|
||||
|
||||
class __PrinterState extends State<_Printer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollController = ScrollController();
|
||||
return ListView(controller: scrollController, children: [
|
||||
outgoing(context),
|
||||
incomming(context),
|
||||
]).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}
|
||||
|
||||
Widget outgoing(BuildContext context) {
|
||||
final isSupportPrinterDriver =
|
||||
bind.mainGetCommonSync(key: 'is-support-printer-driver') == 'true';
|
||||
|
||||
Widget tipOsNotSupported() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-os-requirement-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
Widget tipClientNotInstalled() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child:
|
||||
Text(translate('printer-requires-installed-{$appName}-client-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
Widget tipPrinterNotInstalled() {
|
||||
final failedMsg = ''.obs;
|
||||
platformFFI.registerEventHandler(
|
||||
'install-printer-res', 'install-printer-res', (evt) async {
|
||||
if (evt['success'] as bool) {
|
||||
setState(() {});
|
||||
} else {
|
||||
failedMsg.value = evt['msg'] as String;
|
||||
}
|
||||
}, replace: true);
|
||||
return Column(children: [
|
||||
Obx(
|
||||
() => failedMsg.value.isNotEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-{$appName}-not-installed-tip'))
|
||||
.marginOnly(bottom: 10.0),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => failedMsg.value.isEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(failedMsg.value,
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(color: Colors.red))
|
||||
.marginOnly(bottom: 10.0)),
|
||||
),
|
||||
_Button('Install {$appName} Printer', () {
|
||||
failedMsg.value = '';
|
||||
bind.mainSetCommon(key: 'install-printer', value: '');
|
||||
})
|
||||
]).marginOnly(left: _kCardLeftMargin, bottom: 2.0);
|
||||
}
|
||||
|
||||
Widget tipReady() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-{$appName}-ready-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
final installed = bind.mainIsInstalled();
|
||||
// `is-printer-installed` may fail, but it's rare case.
|
||||
// Add additional error message here if it's really needed.
|
||||
final driver_installed =
|
||||
bind.mainGetCommonSync(key: 'is-printer-installed') == 'true';
|
||||
|
||||
final List<Widget> children = [];
|
||||
if (!isSupportPrinterDriver) {
|
||||
children.add(tipOsNotSupported());
|
||||
} else {
|
||||
children.addAll([
|
||||
if (!installed) tipClientNotInstalled(),
|
||||
if (installed && !driver_installed) tipPrinterNotInstalled(),
|
||||
if (installed && driver_installed) tipReady()
|
||||
]);
|
||||
}
|
||||
return _Card(title: 'Outgoing Print Jobs', children: children);
|
||||
}
|
||||
|
||||
Widget incomming(BuildContext context) {
|
||||
onRadioChanged(String value) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncommingJobAction, value: value);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
PrinterOptions printerOptions = PrinterOptions.load();
|
||||
return _Card(title: 'Incomming Print Jobs', children: [
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobDismiss,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'Dismiss',
|
||||
onChanged: onRadioChanged),
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobDefault,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'use-the-default-printer-tip',
|
||||
onChanged: onRadioChanged),
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobSelected,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'use-the-selected-printer-tip',
|
||||
onChanged: onRadioChanged),
|
||||
if (printerOptions.printerNames.isNotEmpty)
|
||||
ComboBox(
|
||||
initialKey: printerOptions.printerName,
|
||||
keys: printerOptions.printerNames,
|
||||
values: printerOptions.printerNames,
|
||||
enabled: printerOptions.action == kValuePrinterIncomingJobSelected,
|
||||
onChanged: (value) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kKeyPrinterSelected, value: value);
|
||||
setState(() {});
|
||||
},
|
||||
).marginOnly(left: 10),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'auto-print-tip',
|
||||
kKeyPrinterAllowAutoPrint,
|
||||
isServer: false,
|
||||
enabled: printerOptions.action != kValuePrinterIncomingJobDismiss,
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _About extends StatefulWidget {
|
||||
const _About({Key? key}) : super(key: key);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
late final TextEditingController controller;
|
||||
final RxBool startmenu = true.obs;
|
||||
final RxBool desktopicon = true.obs;
|
||||
final RxBool printer = true.obs;
|
||||
final RxBool showProgress = false.obs;
|
||||
final RxBool btnEnabled = true.obs;
|
||||
|
||||
@@ -79,6 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||
printer.value = installOptions['PRINTER'] != '0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -161,7 +163,9 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
).marginSymmetric(vertical: 2 * em),
|
||||
Option(startmenu, label: 'Create start menu shortcuts')
|
||||
.marginOnly(bottom: 7),
|
||||
Option(desktopicon, label: 'Create desktop icon'),
|
||||
Option(desktopicon, label: 'Create desktop icon')
|
||||
.marginOnly(bottom: 7),
|
||||
Option(printer, label: 'Install {$appName} Printer'),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -253,6 +257,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
String args = '';
|
||||
if (startmenu.value) args += ' startmenu';
|
||||
if (desktopicon.value) args += ' desktopicon';
|
||||
if (printer.value) args += ' printer';
|
||||
bind.installInstallMe(options: args, path: controller.text);
|
||||
}
|
||||
|
||||
|
||||
@@ -269,8 +269,10 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow, '${windowId()},$key,$sessionId');
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,RemoteDesktop');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -417,8 +419,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(
|
||||
windowId(), id!, prePeerCount, display, screenRect);
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.RemoteDesktop, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
|
||||
@@ -353,7 +353,9 @@ Widget buildConnectionCard(Client client) {
|
||||
key: ValueKey(client.id),
|
||||
children: [
|
||||
_CmHeader(client: client),
|
||||
client.type_() != ClientType.remote || client.disconnected
|
||||
client.type_() == ClientType.file ||
|
||||
client.type_() == ClientType.portForward ||
|
||||
client.disconnected
|
||||
? Offstage()
|
||||
: _PrivilegeBoard(client: client),
|
||||
Expanded(
|
||||
@@ -526,7 +528,8 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
Offstage(
|
||||
offstage: !client.authorized ||
|
||||
(client.type_() != ClientType.remote &&
|
||||
client.type_() != ClientType.file),
|
||||
client.type_() != ClientType.file &&
|
||||
client.type_() != ClientType.camera),
|
||||
child: IconButton(
|
||||
onPressed: () => checkClickTime(client.id, () {
|
||||
if (client.type_() == ClientType.file) {
|
||||
@@ -627,96 +630,139 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing),
|
||||
mainAxisSpacing: spacing,
|
||||
crossAxisSpacing: spacing,
|
||||
children: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "keyboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "clipboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "audio", enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "file", enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "restart", enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "recording", enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
children: client.type_() == ClientType.camera
|
||||
? [
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "keyboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "clipboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "file",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "restart",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
|
||||
final SimpleWrapper<bool> _firstEnterImage = SimpleWrapper(false);
|
||||
|
||||
// Used to skip session close if "move to new window" is clicked.
|
||||
final Map<String, bool> closeSessionOnDispose = {};
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.toolbarState,
|
||||
this.sessionId,
|
||||
this.tabWindowId,
|
||||
this.password,
|
||||
this.display,
|
||||
this.displays,
|
||||
this.tabController,
|
||||
this.connToken,
|
||||
this.forceRelay,
|
||||
this.isSharedPassword,
|
||||
}) : super(key: key) {
|
||||
initSharedStates(id);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final SessionID? sessionId;
|
||||
final int? tabWindowId;
|
||||
final int? display;
|
||||
final List<int>? displays;
|
||||
final String? password;
|
||||
final ToolbarState toolbarState;
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final SimpleWrapper<State<ViewCameraPage>?> _lastState = SimpleWrapper(null);
|
||||
final DesktopTabController? tabController;
|
||||
|
||||
FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() {
|
||||
final state = _ViewCameraPageState(id);
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
final _cursorOverImage = false.obs;
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||
|
||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||
// to identify the toolbar instance and its callback function.
|
||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
_initStates(id);
|
||||
}
|
||||
|
||||
void _initStates(String id) {}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
});
|
||||
_ffi.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
tabWindowId: widget.tabWindowId,
|
||||
display: widget.display,
|
||||
displays: widget.displays,
|
||||
connToken: widget.connToken,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
DesktopMultiWindow.addListener(this);
|
||||
// if (!_isCustomCursorInited) {
|
||||
// customCursorController.registerNeedUpdateCursorCallback(
|
||||
// (String? lastKey, String? currentKey) async {
|
||||
// if (_firstEnterImage.value) {
|
||||
// _firstEnterImage.value = false;
|
||||
// return true;
|
||||
// }
|
||||
// return lastKey == null || lastKey != currentKey;
|
||||
// });
|
||||
// _isCustomCursorInited = true;
|
||||
// }
|
||||
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController?.onSelected?.call(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = true;
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
super.onWindowRestore();
|
||||
// On windows, we use `onWindowRestore` way to handle window restore from
|
||||
// a minimized state.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
_ffi.dialogManager.hideMobileActionsOverlay();
|
||||
_ffi.imageModel.disposeImage();
|
||||
_ffi.cursorModel.disposeImages();
|
||||
_rawKeyFocusNode.dispose();
|
||||
await _ffi.close(closeSession: closeSession);
|
||||
_timer?.cancel();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
if (closeSession) {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
|
||||
Widget emptyOverlay() => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
remoteToolbar(BuildContext context) => RemoteToolbar(
|
||||
id: widget.id,
|
||||
ffi: _ffi,
|
||||
state: widget.toolbarState,
|
||||
onEnterOrLeaveImageSetter: (id, func) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
|
||||
_onEnterOrLeaveImage4Toolbar = func;
|
||||
},
|
||||
onEnterOrLeaveImageCleaner: (id) {
|
||||
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
|
||||
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
|
||||
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
|
||||
_onEnterOrLeaveImage4Toolbar = null;
|
||||
}
|
||||
},
|
||||
setRemoteState: setState,
|
||||
);
|
||||
|
||||
bodyWidget() {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: kColorCanvas,
|
||||
child: getBodyForDesktop(context),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
_ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay()
|
||||
: () {
|
||||
if (!_ffi.ffiModel.isPeerAndroid) {
|
||||
return Offstage();
|
||||
} else {
|
||||
return Obx(() => Offstage(
|
||||
offstage: _ffi.dialogManager
|
||||
.mobileActionsOverlayVisible.isFalse,
|
||||
child: Overlay(initialEntries: [
|
||||
makeMobileActionsOverlayEntry(
|
||||
() => _ffi.dialogManager
|
||||
.setMobileActionsOverlayVisible(false),
|
||||
ffi: _ffi,
|
||||
)
|
||||
]),
|
||||
));
|
||||
}
|
||||
}(),
|
||||
// Use Overlay to enable rebuild every time on menu button click.
|
||||
_ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(
|
||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
||||
: remoteToolbar(context),
|
||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: Obx(() {
|
||||
final imageReady = _ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isFalse;
|
||||
if (imageReady) {
|
||||
// If the privacy mode(disable physical displays) is switched,
|
||||
// we should not dismiss the dialog immediately.
|
||||
if (DateTime.now().difference(togglePrivacyModeTime) >
|
||||
const Duration(milliseconds: 3000)) {
|
||||
// `dismissAll()` is to ensure that the state is clean.
|
||||
// It's ok to call dismissAll() here.
|
||||
_ffi.dialogManager.dismissAll();
|
||||
// Recreate the block state to refresh the state.
|
||||
_blockableOverlayState = BlockableOverlayState();
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
}
|
||||
// Block the whole `bodyWidget()` when dialog shows.
|
||||
return BlockableOverlay(
|
||||
underlying: bodyWidget(),
|
||||
state: _blockableOverlayState,
|
||||
);
|
||||
} else {
|
||||
// `_blockableOverlayState` is not recreated here.
|
||||
// The toolbar's block state won't work properly when reconnecting, but that's okay.
|
||||
return bodyWidget();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: MultiProvider(providers: [
|
||||
ChangeNotifierProvider.value(value: _ffi.ffiModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.imageModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.cursorModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.canvasModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.recordingModel),
|
||||
], child: buildBody(context)));
|
||||
}
|
||||
|
||||
void enterView(PointerEnterEvent evt) {
|
||||
_cursorOverImage.value = true;
|
||||
_firstEnterImage.value = true;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(true);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.enterOrLeave(true);
|
||||
}
|
||||
}
|
||||
|
||||
void leaveView(PointerExitEvent evt) {
|
||||
if (_ffi.ffiModel.keyboard) {
|
||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||
}
|
||||
|
||||
_cursorOverImage.value = false;
|
||||
_firstEnterImage.value = false;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(false);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRawTouchAndPointerRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return RawTouchGestureDetectorRegion(
|
||||
child: _buildRawPointerMouseRegion(child, onEnter, onExit),
|
||||
ffi: _ffi,
|
||||
isCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRawPointerMouseRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForDesktop(BuildContext context) {
|
||||
var paints = <Widget>[
|
||||
MouseRegion(onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
}, onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}))
|
||||
];
|
||||
|
||||
paints.add(
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: _buildRawTouchAndPointerRegion(
|
||||
QualityMonitor(_ffi.qualityMonitorModel), null, null),
|
||||
),
|
||||
);
|
||||
return Stack(
|
||||
children: paints,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class ImagePaint extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final String id;
|
||||
final RxBool cursorOverImage;
|
||||
final Widget Function(Widget)? listenerBuilder;
|
||||
|
||||
ImagePaint(
|
||||
{Key? key,
|
||||
required this.ffi,
|
||||
required this.id,
|
||||
required this.cursorOverImage,
|
||||
this.listenerBuilder})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ImagePaintState();
|
||||
}
|
||||
|
||||
class _ImagePaintState extends State<ImagePaint> {
|
||||
bool _lastRemoteCursorMoved = false;
|
||||
|
||||
String get id => widget.id;
|
||||
RxBool get cursorOverImage => widget.cursorOverImage;
|
||||
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c, s, Offset.zero, paintSize, isViewOriginal())
|
||||
: _buildScrollbarNonTextureRender(m, paintSize, s);
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
c.updateScrollPercent();
|
||||
return false;
|
||||
},
|
||||
child: Container(
|
||||
child: _buildCrossScrollbarFromLayout(
|
||||
context,
|
||||
_buildListener(paintWidget),
|
||||
c.size,
|
||||
paintSize,
|
||||
c.scrollHorizontal,
|
||||
c.scrollVertical,
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
if (c.size.width > 0 && c.size.height > 0) {
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c,
|
||||
s,
|
||||
Offset(
|
||||
isLinux ? c.x.toInt().toDouble() : c.x,
|
||||
isLinux ? c.y.toInt().toDouble() : c.y,
|
||||
),
|
||||
c.size,
|
||||
isViewOriginal())
|
||||
: _buildScrollAutoNonTextureRender(m, c, s);
|
||||
return Container(child: _buildListener(paintWidget));
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
return CustomPaint(
|
||||
size: imageSize,
|
||||
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollAutoNonTextureRender(
|
||||
ImageModel m, CanvasModel c, double s) {
|
||||
return CustomPaint(
|
||||
size: Size(c.size.width, c.size.height),
|
||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _BuildPaintTextureRender(
|
||||
CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) {
|
||||
final ffiModel = c.parent.target!.ffiModel;
|
||||
final displays = ffiModel.pi.getCurDisplays();
|
||||
final children = <Widget>[];
|
||||
final rect = ffiModel.rect;
|
||||
if (rect == null) {
|
||||
return Container();
|
||||
}
|
||||
final curDisplay = ffiModel.pi.currentDisplay;
|
||||
for (var i = 0; i < displays.length; i++) {
|
||||
final textureId = widget.ffi.textureModel
|
||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||
if (true) {
|
||||
// both "textureId.value != -1" and "true" seems ok
|
||||
children.add(Positioned(
|
||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||
width: displays[i].width * s,
|
||||
height: displays[i].height * s,
|
||||
child: Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
return SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Stack(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = cursor.cache ?? preDefaultCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = preForbiddenCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
Widget _buildCrossScrollbarFromLayout(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
Size layoutSize,
|
||||
Size size,
|
||||
ScrollController horizontal,
|
||||
ScrollController vertical,
|
||||
) {
|
||||
var widget = child;
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: horizontal,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Row(
|
||||
children: [
|
||||
Container(
|
||||
width: ((layoutSize.width - size.width) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: vertical,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Column(
|
||||
children: [
|
||||
Container(
|
||||
height: ((layoutSize.height - size.height) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: horizontal,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
notificationPredicate: layoutSize.height < size.height
|
||||
? (notification) => notification.depth == 1
|
||||
: defaultScrollNotificationPredicate,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: vertical,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
child: widget,
|
||||
width: layoutSize.width,
|
||||
height: layoutSize.height,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListener(Widget child) {
|
||||
if (listenerBuilder != null) {
|
||||
return listenerBuilder!(child);
|
||||
} else {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
|
||||
as mod_menu;
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
class _MenuTheme {
|
||||
static const Color blueColor = MyTheme.button;
|
||||
// kMinInteractiveDimension
|
||||
static const double height = 20.0;
|
||||
static const double dividerHeight = 12.0;
|
||||
}
|
||||
|
||||
class ViewCameraTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
const ViewCameraTabPage({Key? key, required this.params}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ViewCameraTabPage> createState() => _ViewCameraTabPageState(params);
|
||||
}
|
||||
|
||||
class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
final tabController =
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera));
|
||||
final contentKey = UniqueKey();
|
||||
static const IconData selectedIcon = Icons.desktop_windows_sharp;
|
||||
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
|
||||
|
||||
String? peerId;
|
||||
bool _isScreenRectSet = false;
|
||||
int? _display;
|
||||
|
||||
var connectionMap = RxList<Widget>.empty(growable: true);
|
||||
|
||||
_ViewCameraTabPageState(Map<String, dynamic> params) {
|
||||
RemoteCountState.init();
|
||||
peerId = params['id'];
|
||||
final sessionId = params['session_id'];
|
||||
final tabWindowId = params['tab_window_id'];
|
||||
final display = params['display'];
|
||||
final displays = params['displays'];
|
||||
final screenRect = parseParamScreenRect(params);
|
||||
_isScreenRectSet = screenRect != null;
|
||||
_display = display as int?;
|
||||
tryMoveToScreenAndSetFullscreen(screenRect);
|
||||
if (peerId != null) {
|
||||
ConnectionTypeState.init(peerId!);
|
||||
tabController.onSelected = (id) {
|
||||
final viewCameraPage = tabController.widget(id);
|
||||
if (viewCameraPage is ViewCameraPage) {
|
||||
final ffi = viewCameraPage.ffi;
|
||||
bind.setCurSessionId(sessionId: ffi.sessionId);
|
||||
}
|
||||
WindowController.fromWindowId(params['windowId'])
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
UnreadChatCountState.find(id).value = 0;
|
||||
};
|
||||
tabController.add(TabInfo(
|
||||
key: peerId!,
|
||||
label: peerId!,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(peerId),
|
||||
id: peerId!,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: params['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: params['connToken'],
|
||||
forceRelay: params['forceRelay'],
|
||||
isSharedPassword: params['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
_update_remote_count();
|
||||
}
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (!_isScreenRectSet) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: windowId(),
|
||||
peerId: tabController.state.value.tabs.isEmpty
|
||||
? null
|
||||
: tabController.state.value.tabs[0].key,
|
||||
display: _display,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: DesktopTab(
|
||||
controller: tabController,
|
||||
onWindowCloseButton: handleWindowCloseButton,
|
||||
tail: const AddButton(),
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
pageViewBuilder: (pageView) => pageView,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
tabBuilder: (key, icon, label, themeConf) => Obx(() {
|
||||
final connectionType = ConnectionTypeState.find(key);
|
||||
if (!connectionType.isValid()) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
label,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
bool secure =
|
||||
connectionType.secure.value == ConnectionType.strSecure;
|
||||
bool direct =
|
||||
connectionType.direct.value == ConnectionType.strDirect;
|
||||
String msgConn;
|
||||
if (secure && direct) {
|
||||
msgConn = translate("Direct and encrypted connection");
|
||||
} else if (secure && !direct) {
|
||||
msgConn = translate("Relayed and encrypted connection");
|
||||
} else if (!secure && direct) {
|
||||
msgConn = translate("Direct and unencrypted connection");
|
||||
} else {
|
||||
msgConn = translate("Relayed and unencrypted connection");
|
||||
}
|
||||
var msgFingerprint = '${translate('Fingerprint')}:\n';
|
||||
var fingerprint = FingerprintState.find(key).value;
|
||||
if (fingerprint.isEmpty) {
|
||||
fingerprint = 'N/A';
|
||||
}
|
||||
if (fingerprint.length > 5 * 8) {
|
||||
var first = fingerprint.substring(0, 39);
|
||||
var second = fingerprint.substring(40);
|
||||
msgFingerprint += '$first\n$second';
|
||||
} else {
|
||||
msgFingerprint += fingerprint;
|
||||
}
|
||||
|
||||
final tab = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
Tooltip(
|
||||
message: '$msgConn\n$msgFingerprint',
|
||||
child: SvgPicture.asset(
|
||||
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
|
||||
width: themeConf.iconSize,
|
||||
height: themeConf.iconSize,
|
||||
).paddingOnly(right: 5),
|
||||
),
|
||||
label,
|
||||
unreadMessageCountBuilder(UnreadChatCountState.find(key))
|
||||
.marginOnly(left: 4),
|
||||
],
|
||||
);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (e) {
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
return;
|
||||
}
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue &&
|
||||
e.buttons == 2) {
|
||||
showRightMenu(
|
||||
(CancelFunc cancelFunc) {
|
||||
return _tabMenuBuilder(key, cancelFunc);
|
||||
},
|
||||
target: e.position,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: tab,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
final tabWidget = isLinux
|
||||
? buildVirtualWindowFrame(context, child)
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: MyTheme.color(context).border!,
|
||||
width: stateGlobal.windowBorderWidth.value),
|
||||
),
|
||||
child: child,
|
||||
)));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: Obx(() => SubWindowDragToResizeArea(
|
||||
key: contentKey,
|
||||
child: tabWidget,
|
||||
// Specially configured for a better resize area and remote control.
|
||||
childPadding: kDragToResizeAreaPadding,
|
||||
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
|
||||
enableResizeEdges: subWindowManagerEnableResizeEdges,
|
||||
windowId: stateGlobal.windowId,
|
||||
));
|
||||
}
|
||||
|
||||
// Note: Some dup code to ../widgets/remote_toolbar
|
||||
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
|
||||
final List<MenuEntryBase<String>> menu = [];
|
||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final toolbarState = viewCameraPage.toolbarState;
|
||||
menu.addAll([
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
),
|
||||
]);
|
||||
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
final splitAction = MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Move tab to new window'),
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,ViewCamera');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
);
|
||||
menu.insert(1, splitAction);
|
||||
}
|
||||
|
||||
menu.addAll([
|
||||
MenuEntryDivider<String>(),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Copy Fingerprint'),
|
||||
style: style,
|
||||
),
|
||||
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
|
||||
padding: padding,
|
||||
dismissOnClicked: true,
|
||||
dismissCallback: cancelFunc,
|
||||
),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Close'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
tabController.closeBy(key);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
)
|
||||
]);
|
||||
|
||||
return mod_menu.PopupMenu<String>(
|
||||
items: menu
|
||||
.map((entry) => entry.build(
|
||||
context,
|
||||
const MenuConfig(
|
||||
commonColor: _MenuTheme.blueColor,
|
||||
height: _MenuTheme.height,
|
||||
dividerHeight: _MenuTheme.dividerHeight,
|
||||
)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void onRemoveId(String id) async {
|
||||
if (tabController.state.value.tabs.isEmpty) {
|
||||
// Keep calling until the window status is hidden.
|
||||
//
|
||||
// Workaround for Windows:
|
||||
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
|
||||
// `await WindowController.fromWindowId(windowId()).close();`.
|
||||
Future<void> loopCloseWindow() async {
|
||||
int c = 0;
|
||||
final windowController = WindowController.fromWindowId(windowId());
|
||||
while (c < 20 &&
|
||||
tabController.state.value.tabs.isEmpty &&
|
||||
(!await windowController.isHidden())) {
|
||||
await windowController.close();
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
c++;
|
||||
}
|
||||
}
|
||||
|
||||
loopCloseWindow();
|
||||
}
|
||||
ConnectionTypeState.delete(id);
|
||||
_update_remote_count();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
} else {
|
||||
final bool res;
|
||||
if (!option2bool(kOptionEnableConfirmClosingTabs,
|
||||
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
|
||||
res = true;
|
||||
} else {
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
tabController.clear();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
_update_remote_count() =>
|
||||
RemoteCountState.find().value = tabController.length;
|
||||
|
||||
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
|
||||
debugPrint(
|
||||
"[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||
|
||||
dynamic returnValue;
|
||||
// for simplify, just replace connectionId
|
||||
if (call.method == kWindowEventNewViewCamera) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final sessionId = args['session_id'];
|
||||
final tabWindowId = args['tab_window_id'];
|
||||
final display = args['display'];
|
||||
final displays = args['displays'];
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
final prePeerCount = tabController.length;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (stateGlobal.fullscreen.isTrue) {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.ViewCamera, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
});
|
||||
ConnectionTypeState.init(id);
|
||||
tabController.add(TabInfo(
|
||||
key: id,
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: args['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: args['connToken'],
|
||||
forceRelay: args['forceRelay'],
|
||||
isSharedPassword: args['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
} else if (call.method == kWindowDisableGrabKeyboard) {
|
||||
// ???
|
||||
} else if (call.method == "onDestroy") {
|
||||
tabController.clear();
|
||||
} else if (call.method == kWindowActionRebuild) {
|
||||
reloadCurrentWindow();
|
||||
} else if (call.method == kWindowEventActiveSession) {
|
||||
final jumpOk = tabController.jumpToByKey(call.arguments);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventActiveDisplaySession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final display = args['display'];
|
||||
final jumpOk =
|
||||
tabController.jumpToByKeyAndDisplay(id, display, isCamera: true);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventGetRemoteList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => e.key)
|
||||
.toList()
|
||||
.join(',');
|
||||
} else if (call.method == kWindowEventGetSessionIdList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}')
|
||||
.toList()
|
||||
.join(';');
|
||||
} else if (call.method == kWindowEventGetCachedSessionData) {
|
||||
// Ready to show new window and close old tab.
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
try {
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as ViewCameraPage;
|
||||
returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final viewCameraPage =
|
||||
tabController.state.value.selectedTabInfo.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final displayRect = ffi.ffiModel.displaysRect();
|
||||
if (displayRect != null) {
|
||||
final wc = WindowController.fromWindowId(windowId());
|
||||
Rect? frame;
|
||||
try {
|
||||
frame = await wc.getFrame();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"Failed to get frame of window $windowId, it may be hidden");
|
||||
}
|
||||
if (frame != null) {
|
||||
ffi.cursorModel.moveLocal(0, 0);
|
||||
final coords = RemoteWindowCoords(
|
||||
frame,
|
||||
CanvasCoords.fromCanvasModel(ffi.canvasModel),
|
||||
CursorCoords.fromCursorModel(ffi.cursorModel),
|
||||
displayRect);
|
||||
returnValue = jsonEncode(coords.toJson());
|
||||
}
|
||||
}
|
||||
} else if (call.method == kWindowEventSetFullscreen) {
|
||||
stateGlobal.setFullscreen(call.arguments == 'true');
|
||||
}
|
||||
_update_remote_count();
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// multi-tab desktop remote screen
|
||||
class DesktopViewCameraScreen extends StatelessWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) {
|
||||
bind.mainInitInputSource();
|
||||
stateGlobal.getInputSource(force: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: gFFI.ffiModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.imageModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.cursorModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.canvasModel),
|
||||
],
|
||||
child: Scaffold(
|
||||
// Set transparent background for padding the resize area out of the flutter view.
|
||||
// This allows the wallpaper goes through our resize area. (Linux only now).
|
||||
backgroundColor: isLinux ? Colors.transparent : null,
|
||||
body: ViewCameraTabPage(
|
||||
params: params,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,10 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
state: widget.state,
|
||||
setFullscreen: _setFullscreen,
|
||||
));
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
// Do not show keyboard for camera connection type.
|
||||
if (widget.ffi.connType == ConnType.defaultConn) {
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
}
|
||||
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
|
||||
if (!isWeb) {
|
||||
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
|
||||
@@ -1043,23 +1046,26 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
scrollStyle(),
|
||||
imageQuality(),
|
||||
codec(),
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi))
|
||||
if (ffi.connType == ConnType.defaultConn)
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn)
|
||||
_SubmenuButton(
|
||||
ffi: widget.ffi,
|
||||
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
|
||||
child: Text(translate("Virtual display")),
|
||||
),
|
||||
cursorToggles(),
|
||||
if (ffi.connType == ConnType.defaultConn) cursorToggles(),
|
||||
Divider(),
|
||||
toggles(),
|
||||
];
|
||||
// privacy mode
|
||||
if (ffiModel.keyboard && pi.features.privacyMode) {
|
||||
if (ffi.connType == ConnType.defaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
pi.features.privacyMode) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
final privacyModeList =
|
||||
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
||||
@@ -1085,7 +1091,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
menuChildren.add(widget.pluginItem);
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter/material.dart' hide TabBarTheme;
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -51,6 +52,7 @@ enum DesktopTabType {
|
||||
cm,
|
||||
remoteScreen,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
install,
|
||||
}
|
||||
@@ -179,11 +181,13 @@ class DesktopTabController {
|
||||
jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key),
|
||||
callOnSelected: callOnSelected);
|
||||
|
||||
bool jumpToByKeyAndDisplay(String key, int display) {
|
||||
bool jumpToByKeyAndDisplay(String key, int display, {bool isCamera = false}) {
|
||||
for (int i = 0; i < state.value.tabs.length; i++) {
|
||||
final tab = state.value.tabs[i];
|
||||
if (tab.key == key) {
|
||||
final ffi = (tab.page as RemotePage).ffi;
|
||||
final ffi = isCamera
|
||||
? (tab.page as ViewCameraPage).ffi
|
||||
: (tab.page as RemotePage).ffi;
|
||||
if (ffi.ffiModel.pi.currentDisplay == display) {
|
||||
return jumpTo(i, callOnSelected: true);
|
||||
}
|
||||
@@ -725,6 +729,7 @@ class WindowActionPanelState extends State<WindowActionPanel> {
|
||||
return widget.tabController.state.value.tabs.length > 1 &&
|
||||
(widget.tabController.tabType == DesktopTabType.remoteScreen ||
|
||||
widget.tabController.tabType == DesktopTabType.fileTransfer ||
|
||||
widget.tabController.tabType == DesktopTabType.viewCamera ||
|
||||
widget.tabController.tabType == DesktopTabType.portForward ||
|
||||
widget.tabController.tabType == DesktopTabType.cm);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/install_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
||||
@@ -76,6 +77,13 @@ Future<void> main(List<String> args) async {
|
||||
kAppTypeDesktopFileTransfer,
|
||||
);
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
desktopType = DesktopType.viewCamera;
|
||||
runMultiWindow(
|
||||
argument,
|
||||
kAppTypeDesktopViewCamera,
|
||||
);
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
desktopType = DesktopType.portForward;
|
||||
runMultiWindow(
|
||||
@@ -192,6 +200,12 @@ void runMultiWindow(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
draggablePositions.load();
|
||||
widget = DesktopViewCameraScreen(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
widget = DesktopPortForwardScreen(
|
||||
params: argument,
|
||||
@@ -227,6 +241,19 @@ void runMultiWindow(
|
||||
await restoreWindowPosition(WindowType.FileTransfer,
|
||||
windowId: kWindowId!);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
|
||||
if (argument['screen_rect'] == null) {
|
||||
// display can be used to control the offset of the window.
|
||||
await restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: kWindowId!,
|
||||
peerId: argument['id'] as String?,
|
||||
// FIXME: fix display index.
|
||||
display: argument['display'] as int?,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
|
||||
break;
|
||||
|
||||
@@ -204,6 +204,7 @@ class WebHomePage extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String? id;
|
||||
String? password;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
@@ -219,6 +220,11 @@ class WebHomePage extends StatelessWidget {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
isViewCamera = true;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--password':
|
||||
password = args[i + 1];
|
||||
i++;
|
||||
@@ -228,7 +234,7 @@ class WebHomePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
connect(context, id, isFileTransfer: isFileTransfer, password: password);
|
||||
connect(context, id, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, password: password);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,721 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/remote_input.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
|
||||
final initText = '1' * 1024;
|
||||
|
||||
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
|
||||
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
|
||||
// https://github.com/flutter/flutter/issues/159384
|
||||
// https://github.com/flutter/flutter/issues/159383
|
||||
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
|
||||
if (isAndroid) {
|
||||
if (isKeyboardVisible != true) {
|
||||
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage(
|
||||
{Key? key, required this.id, this.password, this.isSharedPassword})
|
||||
: super(key: key);
|
||||
|
||||
final String id;
|
||||
final String? password;
|
||||
final bool? isSharedPassword;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
bool _showBar = !isWebDesktop;
|
||||
bool _showGestureHelp = false;
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
Timer? _timerDidChangeMetrics;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final keyboardVisibilityController = KeyboardVisibilityController();
|
||||
final FocusNode _mobileFocusNode = FocusNode();
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
|
||||
InputModel get inputModel => gFFI.inputModel;
|
||||
SessionID get sessionId => gFFI.sessionId;
|
||||
|
||||
final TextEditingController _textController =
|
||||
TextEditingController(text: initText);
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
initSharedStates(id);
|
||||
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
|
||||
gFFI.dialogManager.loadMobileActionsOverlayVisible();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
gFFI.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
gFFI.chatModel
|
||||
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
|
||||
_blockableOverlayState.applyFfi(gFFI);
|
||||
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
gFFI.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
|
||||
if (gFFI.recordingModel.start) {
|
||||
showToast(translate('Automatically record outgoing sessions'));
|
||||
}
|
||||
_disableAndroidSoftKeyboard(
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||
gFFI.inputModel.listenToMouse(false);
|
||||
gFFI.imageModel.disposeImage();
|
||||
gFFI.cursorModel.disposeImages();
|
||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.dispose();
|
||||
_physicalFocusNode.dispose();
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
// Only one client is considered here for now.
|
||||
gFFI.chatModel.onVoiceCallClosed("End connetion");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
|
||||
// Don't try reset the view style and focus the cursor.
|
||||
if (gFFI.cursorModel.lastKeyboardIsVisible &&
|
||||
gFFI.canvasModel.isMobileCanvasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
|
||||
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
|
||||
if (newBottom != _viewInsetsBottom) {
|
||||
gFFI.canvasModel.mobileFocusCanvasCursor();
|
||||
_viewInsetsBottom = newBottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// to-do: It should be better to use transparent color instead of the bgColor.
|
||||
// But for now, the transparent color will cause the canvas to be white.
|
||||
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
||||
// But I don't know why and how to fix it.
|
||||
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: bgColor,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
|
||||
? getBottomAppBar()
|
||||
: Offstage());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardIsVisible =
|
||||
keyboardVisibilityController.isVisible && _showEdit;
|
||||
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
|
||||
floatingActionButtonLocation: keyboardIsVisible
|
||||
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
|
||||
: null,
|
||||
floatingActionButton: !showActionButton
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
mini: !keyboardIsVisible,
|
||||
child: Icon(
|
||||
(keyboardIsVisible || _showGestureHelp)
|
||||
? Icons.expand_more
|
||||
: Icons.expand_less,
|
||||
color: Colors.white,
|
||||
),
|
||||
backgroundColor: MyTheme.accent,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (keyboardIsVisible) {
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
_mobileFocusNode.unfocus();
|
||||
_physicalFocusNode.requestFocus();
|
||||
} else if (_showGestureHelp) {
|
||||
_showGestureHelp = false;
|
||||
} else {
|
||||
_showBar = !_showBar;
|
||||
}
|
||||
});
|
||||
}),
|
||||
bottomNavigationBar: Obx(() => Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
gFFI.ffiModel.pi.isSet.isTrue &&
|
||||
gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: () {
|
||||
gFFI.ffiModel.tryShowAndroidActionsOverlay();
|
||||
return Offstage();
|
||||
}(),
|
||||
_bottomWidget(),
|
||||
gFFI.ffiModel.pi.isSet.isFalse
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: Offstage(),
|
||||
],
|
||||
)),
|
||||
body: Obx(
|
||||
() => getRawPointerAndKeyBody(Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return Container(
|
||||
color: kColorCanvas,
|
||||
child: SafeArea(
|
||||
child: OrientationBuilder(builder: (ctx, orientation) {
|
||||
if (_currentOrientation != orientation) {
|
||||
Timer(const Duration(milliseconds: 200), () {
|
||||
gFFI.dialogManager
|
||||
.resetMobileActionsOverlay(ffi: gFFI);
|
||||
_currentOrientation = orientation;
|
||||
gFFI.canvasModel.updateViewStyle();
|
||||
});
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
})
|
||||
],
|
||||
)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getRawPointerAndKeyBody(Widget child) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
inputModel: inputModel,
|
||||
// Disable RawKeyFocusScope before the connecting is established.
|
||||
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
|
||||
child: gFFI.ffiModel.pi.isSet.isTrue
|
||||
? RawKeyFocusScope(
|
||||
focusNode: _physicalFocusNode,
|
||||
inputModel: inputModel,
|
||||
child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBottomAppBar() {
|
||||
return BottomAppBar(
|
||||
elevation: 10,
|
||||
color: MyTheme.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.tv),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showOptions(context, widget.id, gFFI.dialogManager);
|
||||
},
|
||||
)
|
||||
] +
|
||||
(isWeb
|
||||
? []
|
||||
: <Widget>[
|
||||
futureBuilder(
|
||||
future: gFFI.invokeMethod(
|
||||
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
|
||||
hasData: (isSupportVoiceCall) => IconButton(
|
||||
color: Colors.white,
|
||||
icon: isAndroid && isSupportVoiceCall
|
||||
? SvgPicture.asset('assets/chat.svg',
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.white, BlendMode.srcIn))
|
||||
: Icon(Icons.message),
|
||||
onPressed: () =>
|
||||
isAndroid && isSupportVoiceCall
|
||||
? showChatOptions(widget.id)
|
||||
: onPressedTextChat(widget.id),
|
||||
))
|
||||
]) +
|
||||
[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showActions(widget.id);
|
||||
},
|
||||
),
|
||||
]),
|
||||
Obx(() => IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.expand_more),
|
||||
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? null
|
||||
: () {
|
||||
setState(() => _showBar = !_showBar);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForMobile() {
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: Stack(children: () {
|
||||
final paints = [
|
||||
ImagePaint(),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: QualityMonitor(gFFI.qualityMonitorModel),
|
||||
),
|
||||
SizedBox(
|
||||
width: 0,
|
||||
height: 0,
|
||||
child: !_showEdit
|
||||
? Container()
|
||||
: TextFormField(
|
||||
textInputAction: TextInputAction.newline,
|
||||
autocorrect: false,
|
||||
// Flutter 3.16.9 Android.
|
||||
// `enableSuggestions` causes secure keyboard to be shown.
|
||||
// https://github.com/flutter/flutter/issues/139143
|
||||
// https://github.com/flutter/flutter/issues/146540
|
||||
// enableSuggestions: false,
|
||||
autofocus: true,
|
||||
focusNode: _mobileFocusNode,
|
||||
maxLines: null,
|
||||
controller: _textController,
|
||||
// trick way to make backspace work always
|
||||
keyboardType: TextInputType.multiline,
|
||||
// `onChanged` may be called depending on the input method if this widget is wrapped in
|
||||
// `Focus(onKeyEvent: ..., child: ...)`
|
||||
// For `Backspace` button in the soft keyboard:
|
||||
// en/fr input method:
|
||||
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
|
||||
// 2. The button will trigger `onKeyEvent` if the text field is empty.
|
||||
// ko/zh/ja input method: the button will trigger `onKeyEvent`
|
||||
// and the event will not popup if `KeyEventResult.handled` is returned.
|
||||
onChanged: null,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
];
|
||||
return paints;
|
||||
}()));
|
||||
}
|
||||
|
||||
Widget getBodyForDesktopWithListener() {
|
||||
var paints = <Widget>[ImagePaint()];
|
||||
return Container(
|
||||
color: MyTheme.canvasColor, child: Stack(children: paints));
|
||||
}
|
||||
|
||||
List<TTextMenu> _getMobileActionMenus() {
|
||||
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
|
||||
!gFFI.ffiModel.keyboard) {
|
||||
return [];
|
||||
}
|
||||
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
|
||||
if (!enabled) return [];
|
||||
return [
|
||||
TTextMenu(
|
||||
child: Text(translate('Back')),
|
||||
onPressed: () => gFFI.inputModel.onMobileBack(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Home')),
|
||||
onPressed: () => gFFI.inputModel.onMobileHome(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Apps')),
|
||||
onPressed: () => gFFI.inputModel.onMobileApps(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume up')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume down')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Power')),
|
||||
onPressed: () => gFFI.inputModel.onMobilePower(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void showActions(String id) async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
final mobileActionMenus = _getMobileActionMenus();
|
||||
final menus = toolbarControls(context, id, gFFI);
|
||||
|
||||
final List<PopupMenuEntry<int>> more = [
|
||||
...mobileActionMenus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) =>
|
||||
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList(),
|
||||
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
|
||||
...menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(
|
||||
child: e.value.getChild(),
|
||||
value: e.key + mobileActionMenus.length))
|
||||
.toList(),
|
||||
];
|
||||
() async {
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: more,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null) {
|
||||
if (index < mobileActionMenus.length) {
|
||||
mobileActionMenus[index].onPressed.call();
|
||||
} else if (index < mobileActionMenus.length + more.length) {
|
||||
menus[index - mobileActionMenus.length].onPressed.call();
|
||||
}
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
onPressedTextChat(String id) {
|
||||
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
|
||||
gFFI.chatModel.toggleChatOverlay();
|
||||
}
|
||||
|
||||
showChatOptions(String id) async {
|
||||
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
|
||||
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
|
||||
|
||||
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
|
||||
{TextStyle? labelStyle}) =>
|
||||
TTextMenu(
|
||||
child: Text(translate(label), style: labelStyle),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
child: IgnorePointer(
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
||||
final isInVoice = [
|
||||
VoiceCallStatus.waitingForResponse,
|
||||
VoiceCallStatus.connected
|
||||
].contains(gFFI.chatModel.voiceCallStatus.value);
|
||||
final menus = [
|
||||
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
|
||||
() => onPressedTextChat(widget.id)),
|
||||
isInVoice
|
||||
? makeTextMenu(
|
||||
'End voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter:
|
||||
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
|
||||
),
|
||||
onPressEndVoiceCall,
|
||||
labelStyle: TextStyle(color: Colors.redAccent))
|
||||
: makeTextMenu(
|
||||
'Voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
|
||||
),
|
||||
onPressVoiceCall),
|
||||
];
|
||||
|
||||
final menuItems = menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList();
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: menuItems,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null && index < menus.length) {
|
||||
menus[index].onPressed.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePaint extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
final c = Provider.of<CanvasModel>(context);
|
||||
var s = c.scale;
|
||||
final adjust = c.getAdjustY();
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showOptions(
|
||||
BuildContext context, String id, OverlayDialogManager dialogManager) async {
|
||||
var displays = <Widget>[];
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
final image = gFFI.ffiModel.getConnectionImage();
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < pi.displays.length; ++i) {
|
||||
children.add(InkWell(
|
||||
onTap: () {
|
||||
if (i == cur) return;
|
||||
openMonitorInTheSameTab(i, gFFI, pi);
|
||||
gFFI.dialogManager.dismissAll();
|
||||
},
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).hintColor),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i == cur
|
||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
||||
: null),
|
||||
child: Center(
|
||||
child: Text((i + 1).toString(),
|
||||
style: TextStyle(
|
||||
color: i == cur ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.bold))))));
|
||||
}
|
||||
displays.add(Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
children: children,
|
||||
)));
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
displays.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
List<TRadioMenu<String>> viewStyleRadios =
|
||||
await toolbarViewStyle(context, id, gFFI);
|
||||
List<TRadioMenu<String>> imageQualityRadios =
|
||||
await toolbarImageQuality(context, id, gFFI);
|
||||
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
|
||||
List<TToggleMenu> displayToggles =
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
|
||||
dialogManager.show((setState, close, context) {
|
||||
var viewStyle =
|
||||
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
|
||||
var imageQuality =
|
||||
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
|
||||
.obs;
|
||||
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
|
||||
final radios = [
|
||||
for (var e in viewStyleRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
viewStyle.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) viewStyle.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in imageQualityRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
imageQuality.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) imageQuality.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in codecRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
codec.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) codec.value = v;
|
||||
}
|
||||
: null)),
|
||||
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
|
||||
];
|
||||
|
||||
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
|
||||
final displayTogglesList = displayToggles
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => Obx(() => CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: rxToggleValues[e.key].value,
|
||||
onChanged: e.value.onChanged != null
|
||||
? (v) {
|
||||
e.value.onChanged?.call(v);
|
||||
if (v != null) rxToggleValues[e.key].value = v;
|
||||
}
|
||||
: null,
|
||||
title: e.value.child)))
|
||||
.toList();
|
||||
final toggles = [
|
||||
...displayTogglesList,
|
||||
];
|
||||
|
||||
var popupDialogMenus = List<Widget>.empty(growable: true);
|
||||
if (popupDialogMenus.isNotEmpty) {
|
||||
popupDialogMenus.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: displays + radios + popupDialogMenus + toggles),
|
||||
);
|
||||
}, clickMaskDismiss: true, backDismiss: true).then((value) {
|
||||
_disableAndroidSoftKeyboard();
|
||||
});
|
||||
}
|
||||
|
||||
class FABLocation extends FloatingActionButtonLocation {
|
||||
FloatingActionButtonLocation location;
|
||||
double offsetX;
|
||||
double offsetY;
|
||||
FABLocation(this.location, this.offsetX, this.offsetY);
|
||||
|
||||
@override
|
||||
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
|
||||
final offset = location.getOffset(scaffoldGeometry);
|
||||
return Offset(offset.dx + offsetX, offset.dy + offsetY);
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,17 @@ class TextureModel {
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
ensureControl(int display) {
|
||||
var ctl = _control[display];
|
||||
if (ctl == null) {
|
||||
|
||||
@@ -30,8 +30,15 @@ enum SortBy {
|
||||
class JobID {
|
||||
int _count = 0;
|
||||
int next() {
|
||||
_count++;
|
||||
return _count;
|
||||
String v = bind.mainGetCommonSync(key: 'transfer-job-id');
|
||||
try {
|
||||
return int.parse(v);
|
||||
} catch (e) {
|
||||
// unreachable. But we still handle it to make it safe.
|
||||
// If we return -1, we have to check it in the caller.
|
||||
_count++;
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,7 @@ class InputModel {
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
|
||||
InputModel(this.parent) {
|
||||
sessionId = parent.target!.sessionId;
|
||||
@@ -471,6 +472,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -525,6 +527,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleKeyEvent(KeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -724,6 +727,7 @@ class InputModel {
|
||||
/// [press] indicates a click event(down and up).
|
||||
void inputKey(String name, {bool? down, bool? press}) {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
bind.sessionInputKey(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -785,6 +789,7 @@ class InputModel {
|
||||
|
||||
/// Send scroll event with scroll distance [y].
|
||||
Future<void> scroll(int y) async {
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json
|
||||
@@ -808,6 +813,7 @@ class InputModel {
|
||||
/// Send mouse press event.
|
||||
Future<void> sendMouse(String type, MouseButtons button) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
@@ -834,6 +840,7 @@ class InputModel {
|
||||
/// Send mouse movement event with distance in [x] and [y].
|
||||
Future<void> moveMouse(double x, double y) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
var x2 = x.toInt();
|
||||
var y2 = y.toInt();
|
||||
await bind.sessionSendMouse(
|
||||
@@ -857,6 +864,7 @@ class InputModel {
|
||||
_lastScale = 1.0;
|
||||
_stopFling = true;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanStart, e.position);
|
||||
}
|
||||
@@ -865,6 +873,7 @@ class InputModel {
|
||||
// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures
|
||||
void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform != kPeerPlatformAndroid) {
|
||||
final scale = ((e.scale - _lastScale) * 1000).toInt();
|
||||
_lastScale = e.scale;
|
||||
@@ -904,6 +913,7 @@ class InputModel {
|
||||
handlePointerEvent('touch', kMouseEventTypePanUpdate,
|
||||
Offset(x.toDouble(), y.toDouble()));
|
||||
} else {
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: '{"type": "trackpad", "x": "$x", "y": "$y"}');
|
||||
@@ -912,6 +922,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void _scheduleFling(double x, double y, int delay) {
|
||||
if (isViewCamera) return;
|
||||
if ((x == 0 && y == 0) || _stopFling) {
|
||||
_fling = false;
|
||||
return;
|
||||
@@ -963,6 +974,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void onPointerPanZoomEnd(PointerPanZoomEndEvent e) {
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanEnd, e.position);
|
||||
return;
|
||||
@@ -994,6 +1006,7 @@ class InputModel {
|
||||
_remoteWindowCoords = [];
|
||||
_windowRect = null;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
if (isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = false;
|
||||
@@ -1007,6 +1020,7 @@ class InputModel {
|
||||
void onPointUpImage(PointerUpEvent e) {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
@@ -1015,6 +1029,7 @@ class InputModel {
|
||||
|
||||
void onPointMoveImage(PointerMoveEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (_queryOtherWindowCoords) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
@@ -1049,6 +1064,7 @@ class InputModel {
|
||||
|
||||
void onPointerSignalImage(PointerSignalEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e is PointerScrollEvent) {
|
||||
var dx = e.scrollDelta.dx.toInt();
|
||||
var dy = e.scrollDelta.dy.toInt();
|
||||
@@ -1146,6 +1162,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
final evt = PointerEventToRust(kind, type, evtValue).toJson();
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendPointer(
|
||||
sessionId: sessionId, msg: json.encode(modify(evt)));
|
||||
}
|
||||
@@ -1177,6 +1194,7 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
}) {
|
||||
if (isViewCamera) return;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
if (_checkPeerControlProtected(x, y)) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
@@ -19,6 +18,7 @@ import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:flutter_hbb/models/group_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -407,15 +407,191 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.fileModel.sendEmptyDirs(evt);
|
||||
}
|
||||
} else if (name == "record_status") {
|
||||
if (desktopType == DesktopType.remote || isMobile) {
|
||||
if (desktopType == DesktopType.remote ||
|
||||
desktopType == DesktopType.viewCamera ||
|
||||
isMobile) {
|
||||
parent.target?.recordingModel.updateStatus(evt['start'] == 'true');
|
||||
}
|
||||
} else if (name == "printer_request") {
|
||||
_handlePrinterRequest(evt, sessionId, peerId);
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_handlePrinterRequest(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
final id = evt['id'];
|
||||
final path = evt['path'];
|
||||
final dialogManager = parent.target!.dialogManager;
|
||||
dialogManager.show((setState, close, context) {
|
||||
PrinterOptions printerOptions = PrinterOptions.load();
|
||||
final saveSettings = mainGetLocalBoolOptionSync(kKeyPrinterSave).obs;
|
||||
final dontShowAgain = false.obs;
|
||||
final Rx<String> selectedPrinterName = printerOptions.printerName.obs;
|
||||
final printerNames = printerOptions.printerNames;
|
||||
final defaultOrSelectedGroupValue =
|
||||
(printerOptions.action == kValuePrinterIncomingJobDismiss
|
||||
? kValuePrinterIncomingJobDefault
|
||||
: printerOptions.action)
|
||||
.obs;
|
||||
|
||||
onRatioChanged(String? value) {
|
||||
defaultOrSelectedGroupValue.value =
|
||||
value ?? kValuePrinterIncomingJobDefault;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
final printerName = defaultOrSelectedGroupValue.isEmpty
|
||||
? ''
|
||||
: selectedPrinterName.value;
|
||||
bind.sessionPrinterResponse(
|
||||
sessionId: sessionId, id: id, path: path, printerName: printerName);
|
||||
if (saveSettings.value || dontShowAgain.value) {
|
||||
bind.mainSetLocalOption(key: kKeyPrinterSelected, value: printerName);
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncommingJobAction,
|
||||
value: defaultOrSelectedGroupValue.value);
|
||||
}
|
||||
if (dontShowAgain.value) {
|
||||
mainSetLocalBoolOption(kKeyPrinterAllowAutoPrint, true);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (dontShowAgain.value) {
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncommingJobAction,
|
||||
value: kValuePrinterIncomingJobDismiss);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
final printerItemHeight = 30.0;
|
||||
final selectionAreaHeight =
|
||||
printerItemHeight * min(8.0, max(printerNames.length, 3.0));
|
||||
final content = Column(
|
||||
children: [
|
||||
Text(translate('print-incoming-job-confirm-tip')),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Radio<String>(
|
||||
value: kValuePrinterIncomingJobDefault,
|
||||
groupValue: defaultOrSelectedGroupValue.value,
|
||||
onChanged: onRatioChanged)),
|
||||
GestureDetector(
|
||||
child: Text(translate('use-the-default-printer-tip')),
|
||||
onTap: () => onRatioChanged(kValuePrinterIncomingJobDefault)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Row(children: [
|
||||
Obx(() => Radio<String>(
|
||||
value: kValuePrinterIncomingJobSelected,
|
||||
groupValue: defaultOrSelectedGroupValue.value,
|
||||
onChanged: onRatioChanged)),
|
||||
GestureDetector(
|
||||
child: Text(translate('use-the-selected-printer-tip')),
|
||||
onTap: () =>
|
||||
onRatioChanged(kValuePrinterIncomingJobSelected)),
|
||||
]),
|
||||
SizedBox(
|
||||
height: selectionAreaHeight,
|
||||
width: 500,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
return Obx(() => GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selectedPrinterName.value ==
|
||||
printerNames[index]
|
||||
? (defaultOrSelectedGroupValue.value ==
|
||||
kValuePrinterIncomingJobSelected
|
||||
? MyTheme.button
|
||||
: MyTheme.button.withOpacity(0.5))
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(5.0),
|
||||
),
|
||||
),
|
||||
key: ValueKey(printerNames[index]),
|
||||
height: printerItemHeight,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
printerNames[index],
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: defaultOrSelectedGroupValue.value ==
|
||||
kValuePrinterIncomingJobSelected
|
||||
? () {
|
||||
selectedPrinterName.value =
|
||||
printerNames[index];
|
||||
}
|
||||
: null,
|
||||
));
|
||||
},
|
||||
itemCount: printerNames.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Checkbox(
|
||||
value: saveSettings.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
saveSettings.value = value;
|
||||
mainSetLocalBoolOption(kKeyPrinterSave, value);
|
||||
}
|
||||
})),
|
||||
GestureDetector(
|
||||
child: Text(translate('save-settings-tip')),
|
||||
onTap: () {
|
||||
saveSettings.value = !saveSettings.value;
|
||||
mainSetLocalBoolOption(kKeyPrinterSave, saveSettings.value);
|
||||
}),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Checkbox(
|
||||
value: dontShowAgain.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
dontShowAgain.value = value;
|
||||
}
|
||||
})),
|
||||
GestureDetector(
|
||||
child: Text(translate('dont-show-again-tip')),
|
||||
onTap: () {
|
||||
dontShowAgain.value = !dontShowAgain.value;
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Incoming Print Job')),
|
||||
content: content,
|
||||
actions: [
|
||||
dialogButton('OK', onPressed: onSubmit),
|
||||
dialogButton('Cancel', onPressed: onCancel),
|
||||
],
|
||||
onSubmit: onSubmit,
|
||||
onCancel: onCancel,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_handleUseTextureRender(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
|
||||
@@ -501,7 +677,9 @@ class FfiModel with ChangeNotifier {
|
||||
final display = int.parse(evt['display']);
|
||||
|
||||
if (_pi.currentDisplay != kAllDisplayValue) {
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) > 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) >
|
||||
1) {
|
||||
if (display != _pi.currentDisplay) {
|
||||
return;
|
||||
}
|
||||
@@ -809,7 +987,9 @@ class FfiModel with ChangeNotifier {
|
||||
_pi.primaryDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) <= 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) <=
|
||||
1) {
|
||||
_pi.currentDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
@@ -827,9 +1007,11 @@ class FfiModel with ChangeNotifier {
|
||||
sessionId: sessionId, arg: kOptionTouchMode) !=
|
||||
'';
|
||||
}
|
||||
// FIXME: handle ViewCamera ConnType independently.
|
||||
if (connType == ConnType.fileTransfer) {
|
||||
parent.target?.fileModel.onReady();
|
||||
} else if (connType == ConnType.defaultConn) {
|
||||
} else if (connType == ConnType.defaultConn ||
|
||||
connType == ConnType.viewCamera) {
|
||||
List<Display> newDisplays = [];
|
||||
List<dynamic> displays = json.decode(evt['displays']);
|
||||
for (int i = 0; i < displays.length; ++i) {
|
||||
@@ -859,7 +1041,7 @@ class FfiModel with ChangeNotifier {
|
||||
bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly));
|
||||
}
|
||||
if (connType == ConnType.defaultConn) {
|
||||
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
|
||||
final platformAdditions = evt['platform_additions'];
|
||||
if (platformAdditions != null && platformAdditions != '') {
|
||||
try {
|
||||
@@ -2576,7 +2758,8 @@ class ElevationModel with ChangeNotifier {
|
||||
onPortableServiceRunning(bool running) => _running = running;
|
||||
}
|
||||
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp }
|
||||
// The index values of `ConnType` are same as rust protobuf.
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp, viewCamera }
|
||||
|
||||
/// Flutter state manager and data communication with the Rust core.
|
||||
class FFI {
|
||||
@@ -2651,10 +2834,11 @@ class FFI {
|
||||
ffiModel.waitForImageTimer = null;
|
||||
}
|
||||
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward].
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward].
|
||||
void start(
|
||||
String id, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isPortForward = false,
|
||||
bool isRdp = false,
|
||||
String? switchUuid,
|
||||
@@ -2669,9 +2853,15 @@ class FFI {
|
||||
closed = false;
|
||||
auditNote = '';
|
||||
if (isMobile) mobileReset();
|
||||
assert(!(isFileTransfer && isPortForward), 'more than one connect type');
|
||||
assert(
|
||||
(!(isPortForward && isViewCamera)) &&
|
||||
(!(isViewCamera && isPortForward)) &&
|
||||
(!(isPortForward && isFileTransfer)),
|
||||
'more than one connect type');
|
||||
if (isFileTransfer) {
|
||||
connType = ConnType.fileTransfer;
|
||||
} else if (isViewCamera) {
|
||||
connType = ConnType.viewCamera;
|
||||
} else if (isPortForward) {
|
||||
connType = ConnType.portForward;
|
||||
} else {
|
||||
@@ -2691,6 +2881,7 @@ class FFI {
|
||||
sessionId: sessionId,
|
||||
id: id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isPortForward: isPortForward,
|
||||
isRdp: isRdp,
|
||||
switchUuid: switchUuid ?? '',
|
||||
@@ -2706,7 +2897,10 @@ class FFI {
|
||||
return;
|
||||
}
|
||||
final addRes = bind.sessionAddExistedSync(
|
||||
id: id, sessionId: sessionId, displays: Int32List.fromList(displays));
|
||||
id: id,
|
||||
sessionId: sessionId,
|
||||
displays: Int32List.fromList(displays),
|
||||
isViewCamera: isViewCamera);
|
||||
if (addRes != '') {
|
||||
debugPrint(
|
||||
'Unreachable, failed to add existed session to $id, $addRes');
|
||||
@@ -2717,6 +2911,11 @@ class FFI {
|
||||
if (isDesktop && connType == ConnType.defaultConn) {
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
// FIXME: separate cameras displays or shift all indices.
|
||||
if (isDesktop && connType == ConnType.viewCamera) {
|
||||
// FIXME: currently the default 0 is not used.
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
|
||||
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
|
||||
// Though the stream is returned immediately, the stream may not be ready.
|
||||
@@ -2993,6 +3192,9 @@ class PeerInfo with ChangeNotifier {
|
||||
bool get isAmyuniIdd =>
|
||||
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
|
||||
|
||||
bool get isSupportViewCamera =>
|
||||
platformAdditions[kPlatformAdditionsSupportViewCamera] == true;
|
||||
|
||||
Display? tryGetDisplay({int? display}) {
|
||||
if (displays.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -60,14 +60,14 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
bool registerEventHandler(
|
||||
String eventName, String handlerName, HandleEvent handler) {
|
||||
String eventName, String handlerName, HandleEvent handler, {bool replace = false}) {
|
||||
debugPrint('registerEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers == null) {
|
||||
_eventHandlers[eventName] = {handlerName: handler};
|
||||
return true;
|
||||
} else {
|
||||
if (handlers.containsKey(handlerName)) {
|
||||
if (!replace && handlers.containsKey(handlerName)) {
|
||||
return false;
|
||||
} else {
|
||||
handlers[handlerName] = handler;
|
||||
|
||||
48
flutter/lib/models/printer_model.dart
Normal file
48
flutter/lib/models/printer_model.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
class PrinterOptions {
|
||||
String action;
|
||||
List<String> printerNames;
|
||||
String printerName;
|
||||
|
||||
PrinterOptions(
|
||||
{required this.action,
|
||||
required this.printerNames,
|
||||
required this.printerName});
|
||||
|
||||
static PrinterOptions load() {
|
||||
var action = bind.mainGetLocalOption(key: kKeyPrinterIncommingJobAction);
|
||||
if (![
|
||||
kValuePrinterIncomingJobDismiss,
|
||||
kValuePrinterIncomingJobDefault,
|
||||
kValuePrinterIncomingJobSelected
|
||||
].contains(action)) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
}
|
||||
|
||||
final printerNames = getPrinterNames();
|
||||
var selectedPrinterName = bind.mainGetLocalOption(key: kKeyPrinterSelected);
|
||||
if (!printerNames.contains(selectedPrinterName)) {
|
||||
if (action == kValuePrinterIncomingJobSelected) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncommingJobAction,
|
||||
value: kValuePrinterIncomingJobDefault);
|
||||
if (printerNames.isEmpty) {
|
||||
selectedPrinterName = '';
|
||||
} else {
|
||||
selectedPrinterName = printerNames.first;
|
||||
}
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterSelected, value: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
|
||||
return PrinterOptions(
|
||||
action: action,
|
||||
printerNames: printerNames,
|
||||
printerName: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
@@ -791,6 +791,7 @@ class ServerModel with ChangeNotifier {
|
||||
enum ClientType {
|
||||
remote,
|
||||
file,
|
||||
camera,
|
||||
portForward,
|
||||
}
|
||||
|
||||
@@ -798,6 +799,7 @@ class Client {
|
||||
int id = 0; // client connections inner count id
|
||||
bool authorized = false;
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String portForward = "";
|
||||
String name = "";
|
||||
String peerId = ""; // peer user's id,show at app
|
||||
@@ -815,13 +817,15 @@ class Client {
|
||||
|
||||
RxInt unreadChatMessageCount = 0.obs;
|
||||
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId,
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera, this.name, this.peerId,
|
||||
this.keyboard, this.clipboard, this.audio);
|
||||
|
||||
Client.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
authorized = json['authorized'];
|
||||
isFileTransfer = json['is_file_transfer'];
|
||||
// TODO: no entry then default.
|
||||
isViewCamera = json['is_view_camera'];
|
||||
portForward = json['port_forward'];
|
||||
name = json['name'];
|
||||
peerId = json['peer_id'];
|
||||
@@ -843,6 +847,7 @@ class Client {
|
||||
data['id'] = id;
|
||||
data['authorized'] = authorized;
|
||||
data['is_file_transfer'] = isFileTransfer;
|
||||
data['is_view_camera'] = isViewCamera;
|
||||
data['port_forward'] = portForward;
|
||||
data['name'] = name;
|
||||
data['peer_id'] = peerId;
|
||||
@@ -863,6 +868,8 @@ class Client {
|
||||
ClientType type_() {
|
||||
if (isFileTransfer) {
|
||||
return ClientType.file;
|
||||
} else if (isViewCamera) {
|
||||
return ClientType.camera;
|
||||
} else if (portForward.isNotEmpty) {
|
||||
return ClientType.portForward;
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,14 @@ import 'package:flutter_hbb/models/input_model.dart';
|
||||
|
||||
/// must keep the order
|
||||
// ignore: constant_identifier_names
|
||||
enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown }
|
||||
enum WindowType {
|
||||
Main,
|
||||
RemoteDesktop,
|
||||
FileTransfer,
|
||||
ViewCamera,
|
||||
PortForward,
|
||||
Unknown
|
||||
}
|
||||
|
||||
extension Index on int {
|
||||
WindowType get windowType {
|
||||
@@ -23,6 +30,8 @@ extension Index on int {
|
||||
case 2:
|
||||
return WindowType.FileTransfer;
|
||||
case 3:
|
||||
return WindowType.ViewCamera;
|
||||
case 4:
|
||||
return WindowType.PortForward;
|
||||
default:
|
||||
return WindowType.Unknown;
|
||||
@@ -50,31 +59,46 @@ class RustDeskMultiWindowManager {
|
||||
final List<AsyncCallback> _windowActiveCallbacks = List.empty(growable: true);
|
||||
final List<int> _remoteDesktopWindows = List.empty(growable: true);
|
||||
final List<int> _fileTransferWindows = List.empty(growable: true);
|
||||
final List<int> _viewCameraWindows = List.empty(growable: true);
|
||||
final List<int> _portForwardWindows = List.empty(growable: true);
|
||||
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId) async {
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId,
|
||||
WindowType windowType) async {
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType.index,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
if (windowType == WindowType.RemoteDesktop) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
} else if (windowType == WindowType.ViewCamera) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
peerId,
|
||||
_viewCameraWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This function must be called in the main window thread.
|
||||
// Because the _remoteDesktopWindows is managed in that thread.
|
||||
openMonitorSession(int windowId, String peerId, int display, int displayCount,
|
||||
Rect? screenRect) async {
|
||||
if (_remoteDesktopWindows.length > 1) {
|
||||
for (final windowId in _remoteDesktopWindows) {
|
||||
Rect? screenRect, int windowType) async {
|
||||
final isCamera = windowType == WindowType.ViewCamera.index;
|
||||
final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows;
|
||||
if (windowIDs.length > 1) {
|
||||
for (final windowId in windowIDs) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId,
|
||||
kWindowEventActiveDisplaySession,
|
||||
@@ -91,7 +115,7 @@ class RustDeskMultiWindowManager {
|
||||
? List.generate(displayCount, (index) => index)
|
||||
: [display];
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'display': display,
|
||||
@@ -107,10 +131,10 @@ class RustDeskMultiWindowManager {
|
||||
}
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
windowType.windowType,
|
||||
isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
windowIDs,
|
||||
jsonEncode(params),
|
||||
screenRect: screenRect,
|
||||
);
|
||||
@@ -277,6 +301,27 @@ class RustDeskMultiWindowManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newViewCamera(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
String? switchUuid,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
remoteId,
|
||||
_viewCameraWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
switchUuid: switchUuid,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newPortForward(
|
||||
String remoteId,
|
||||
bool isRDP, {
|
||||
@@ -324,6 +369,8 @@ class RustDeskMultiWindowManager {
|
||||
return _remoteDesktopWindows;
|
||||
case WindowType.FileTransfer:
|
||||
return _fileTransferWindows;
|
||||
case WindowType.ViewCamera:
|
||||
return _viewCameraWindows;
|
||||
case WindowType.PortForward:
|
||||
return _portForwardWindows;
|
||||
case WindowType.Unknown:
|
||||
@@ -342,6 +389,9 @@ class RustDeskMultiWindowManager {
|
||||
case WindowType.FileTransfer:
|
||||
_fileTransferWindows.clear();
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
_viewCameraWindows.clear();
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
_portForwardWindows.clear();
|
||||
break;
|
||||
|
||||
@@ -60,7 +60,8 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("hostStopSystemKeyPropagate");
|
||||
}
|
||||
|
||||
int peerGetDefaultSessionsCount({required String id, dynamic hint}) {
|
||||
int peerGetSessionsCount(
|
||||
{required String id, required int connType, dynamic hint}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ class RustdeskImpl {
|
||||
{required String id,
|
||||
required UuidValue sessionId,
|
||||
required Int32List displays,
|
||||
required bool isViewCamera,
|
||||
dynamic hint}) {
|
||||
return '';
|
||||
}
|
||||
@@ -76,6 +78,7 @@ class RustdeskImpl {
|
||||
{required UuidValue sessionId,
|
||||
required String id,
|
||||
required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isPortForward,
|
||||
required bool isRdp,
|
||||
required String switchUuid,
|
||||
@@ -90,7 +93,8 @@ class RustdeskImpl {
|
||||
'id': id,
|
||||
'password': password,
|
||||
'is_shared_password': isSharedPassword,
|
||||
'isFileTransfer': isFileTransfer
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -525,8 +525,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
|
||||
resolved-ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
|
||||
ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.3.8+57
|
||||
version: 1.3.9+57
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -94,7 +94,7 @@ dependencies:
|
||||
flutter_gpu_texture_renderer:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer
|
||||
ref: 2ded7f146437a761ffe6981e2f742038f85ca68d
|
||||
ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87
|
||||
uuid: ^3.0.7
|
||||
auto_size_text_field: ^2.2.1
|
||||
flex_color_picker: ^3.3.0
|
||||
@@ -164,6 +164,9 @@ flutter:
|
||||
- family: DeviceGroup
|
||||
fonts:
|
||||
- asset: assets/device_group.ttf
|
||||
- family: More
|
||||
fonts:
|
||||
- asset: assets/more.ttf
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||
Client(1, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||
Client(2, false, false, "UserC", "331123123", true, false, false),
|
||||
Client(3, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false)
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
||||
];
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
||||
|
||||
@@ -47,3 +47,11 @@ fuser = {version = "0.15", default-features = false, optional = true}
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true}
|
||||
# Use `relax-void-encoding`, as that allows us to pass `c_void` instead of implementing `Encode` correctly for `&CGImageRef`
|
||||
objc2 = { version = "0.5.1", features = ["relax-void-encoding"] }
|
||||
objc2-foundation = { version = "0.2.0", features = ["NSArray", "NSString", "NSEnumerator", "NSGeometry", "NSProgress"] }
|
||||
objc2-app-kit = { version = "0.2.0", features = ["NSPasteboard", "NSPasteboardItem", "NSImage", "NSFilePromiseProvider"] }
|
||||
uuid = { version = "1.3", features = ["v4"] }
|
||||
fsevent = "2.1.2"
|
||||
dirs = "5.0"
|
||||
xattr = "1.4.0"
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
use hbb_common::{log, ResultType};
|
||||
use std::sync::Mutex;
|
||||
use std::{ops::Deref, sync::Mutex};
|
||||
|
||||
use crate::CliprdrServiceContext;
|
||||
|
||||
const CLIPBOARD_RESPONSE_WAIT_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONTEXT_SEND: ContextSend = ContextSend{addr: Mutex::new(None)};
|
||||
static ref CONTEXT_SEND: ContextSend = ContextSend::default();
|
||||
}
|
||||
|
||||
pub struct ContextSend {
|
||||
addr: Mutex<Option<Box<dyn CliprdrServiceContext>>>,
|
||||
#[derive(Default)]
|
||||
pub struct ContextSend(Mutex<Option<Box<dyn CliprdrServiceContext>>>);
|
||||
|
||||
impl Deref for ContextSend {
|
||||
type Target = Mutex<Option<Box<dyn CliprdrServiceContext>>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSend {
|
||||
#[inline]
|
||||
pub fn is_enabled() -> bool {
|
||||
CONTEXT_SEND.addr.lock().unwrap().is_some()
|
||||
CONTEXT_SEND.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
pub fn set_is_stopped() {
|
||||
@@ -24,7 +31,7 @@ impl ContextSend {
|
||||
}
|
||||
|
||||
pub fn enable(enabled: bool) {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
if enabled {
|
||||
if lock.is_some() {
|
||||
return;
|
||||
@@ -49,7 +56,7 @@ impl ContextSend {
|
||||
|
||||
/// make sure the clipboard context is enabled.
|
||||
pub fn make_sure_enabled() -> ResultType<()> {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
if lock.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -63,7 +70,7 @@ impl ContextSend {
|
||||
pub fn proc<F: FnOnce(&mut Box<dyn CliprdrServiceContext>) -> ResultType<()>>(
|
||||
f: F,
|
||||
) -> ResultType<()> {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
match lock.as_mut() {
|
||||
Some(context) => f(context),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
use hbb_common::ResultType;
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
use hbb_common::{allow_err, log};
|
||||
@@ -14,10 +17,16 @@ use hbb_common::{
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub mod context_send;
|
||||
pub mod platform;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub use context_send::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -27,9 +36,18 @@ const ERR_CODE_INVALID_PARAMETER: u32 = 0x00000002;
|
||||
#[cfg(target_os = "windows")]
|
||||
const ERR_CODE_SEND_MSG: u32 = 0x00000003;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub(crate) use platform::create_cliprdr_context;
|
||||
|
||||
pub struct ProgressPercent {
|
||||
pub percent: f64,
|
||||
pub is_canceled: bool,
|
||||
pub is_failed: bool,
|
||||
}
|
||||
|
||||
// to-do: This trait may be removed, because unix file copy paste does not need it.
|
||||
/// Ability to handle Clipboard File from remote rustdesk client
|
||||
///
|
||||
@@ -44,6 +62,10 @@ pub trait CliprdrServiceContext: Send + Sync {
|
||||
fn empty_clipboard(&mut self, conn_id: i32) -> Result<bool, CliprdrError>;
|
||||
/// run as a server for clipboard RPC
|
||||
fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>;
|
||||
/// get the progress of the paste task.
|
||||
fn get_progress_percent(&self) -> Option<ProgressPercent>;
|
||||
/// cancel the paste task.
|
||||
fn cancel(&mut self);
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -62,11 +84,11 @@ pub enum CliprdrError {
|
||||
ConversionFailure,
|
||||
#[error("failure to read clipboard")]
|
||||
OpenClipboard,
|
||||
#[error("failure to read file metadata or content")]
|
||||
#[error("failure to read file metadata or content, path: {path}, err: {err}")]
|
||||
FileError { path: String, err: std::io::Error },
|
||||
#[error("invalid request")]
|
||||
#[error("invalid request: {description}")]
|
||||
InvalidRequest { description: String },
|
||||
#[error("common request")]
|
||||
#[error("common request: {description}")]
|
||||
CommonError { description: String },
|
||||
#[error("unknown cliprdr error")]
|
||||
Unknown(u32),
|
||||
|
||||
@@ -14,3 +14,13 @@ pub fn create_cliprdr_context(
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
pub mod unix;
|
||||
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))]
|
||||
pub fn create_cliprdr_context(
|
||||
_enable_files: bool,
|
||||
_enable_others: bool,
|
||||
_response_wait_timeout_secs: u32,
|
||||
) -> crate::ResultType<Box<dyn crate::CliprdrServiceContext>> {
|
||||
let boxed = unix::macos::pasteboard_context::create_pasteboard_context()? as Box<_>;
|
||||
Ok(boxed)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ use hbb_common::{
|
||||
bytes::{Buf, Bytes},
|
||||
log,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use utf16string::WStr;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type Inode = u64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FileType {
|
||||
File,
|
||||
Directory,
|
||||
@@ -28,10 +30,11 @@ pub const PERM_RW: u16 = 0o644;
|
||||
pub const PERM_SELF_RO: u16 = 0o400;
|
||||
/// rwx
|
||||
pub const PERM_RWX: u16 = 0o755;
|
||||
#[allow(dead_code)]
|
||||
/// max length of file name
|
||||
pub const MAX_NAME_LEN: usize = 255;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileDescription {
|
||||
pub conn_id: i32,
|
||||
pub name: PathBuf,
|
||||
@@ -40,9 +43,7 @@ pub struct FileDescription {
|
||||
pub last_modified: SystemTime,
|
||||
pub last_metadata_changed: SystemTime,
|
||||
pub creation_time: SystemTime,
|
||||
|
||||
pub size: u64,
|
||||
|
||||
pub perm: u16,
|
||||
}
|
||||
|
||||
@@ -144,7 +145,6 @@ impl FileDescription {
|
||||
atime: last_modified,
|
||||
last_modified,
|
||||
last_metadata_changed: last_modified,
|
||||
|
||||
creation_time: last_modified,
|
||||
size,
|
||||
perm,
|
||||
|
||||
25
libs/clipboard/src/platform/unix/macos/README.md
Normal file
25
libs/clipboard/src/platform/unix/macos/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# File pate on macOS
|
||||
|
||||
MacOS cannot use `fuse` because of [macfuse is not supported by default](https://github.com/macfuse/macfuse/wiki/Getting-Started#enabling-support-for-third-party-kernel-extensions-apple-silicon-macs).
|
||||
|
||||
1. Use a temporary file `/tmp/rustdesk_<uuid>` as a placeholder in the pasteboard.
|
||||
2. Uses `fsevent` to observe files paste operation. Then perform pasting files.
|
||||
|
||||
## Files
|
||||
|
||||
### `pasteboard_context.rs`
|
||||
|
||||
The context manager of the paste operations.
|
||||
|
||||
### `item_data_provider.rs`
|
||||
|
||||
1. Set pasteboard item.
|
||||
2. Create temp file in `/tmp/.rustdesk_*`.
|
||||
|
||||
### `paste_observer.rs`
|
||||
|
||||
Use `fsevent` to observe the paste operation with the source file `/tmp/.rustdesk_*`.
|
||||
|
||||
### `paste_task.rs`
|
||||
|
||||
Perform the paste.
|
||||
77
libs/clipboard/src/platform/unix/macos/item_data_provider.rs
Normal file
77
libs/clipboard/src/platform/unix/macos/item_data_provider.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use super::pasteboard_context::{PasteObserverInfo, TEMP_FILE_PREFIX};
|
||||
use objc2::{
|
||||
declare_class, msg_send_id, mutability,
|
||||
rc::Id,
|
||||
runtime::{NSObject, NSObjectProtocol},
|
||||
ClassType, DeclaredClass,
|
||||
};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardItem, NSPasteboardItemDataProvider, NSPasteboardType,
|
||||
NSPasteboardTypeFileURL,
|
||||
};
|
||||
use objc2_foundation::NSString;
|
||||
use std::{io::Result, sync::mpsc::Sender};
|
||||
|
||||
pub(super) struct Ivars {
|
||||
task_info: PasteObserverInfo,
|
||||
tx: Sender<Result<PasteObserverInfo>>,
|
||||
}
|
||||
|
||||
declare_class!(
|
||||
pub(super) struct PasteboardFileUrlProvider;
|
||||
|
||||
unsafe impl ClassType for PasteboardFileUrlProvider {
|
||||
type Super = NSObject;
|
||||
type Mutability = mutability::InteriorMutable;
|
||||
const NAME: &'static str = "PasteboardFileUrlProvider";
|
||||
}
|
||||
|
||||
impl DeclaredClass for PasteboardFileUrlProvider {
|
||||
type Ivars = Ivars;
|
||||
}
|
||||
|
||||
unsafe impl NSObjectProtocol for PasteboardFileUrlProvider {}
|
||||
|
||||
unsafe impl NSPasteboardItemDataProvider for PasteboardFileUrlProvider {
|
||||
#[method(pasteboard:item:provideDataForType:)]
|
||||
#[allow(non_snake_case)]
|
||||
unsafe fn pasteboard_item_provideDataForType(
|
||||
&self,
|
||||
_pasteboard: Option<&NSPasteboard>,
|
||||
item: &NSPasteboardItem,
|
||||
r#type: &NSPasteboardType,
|
||||
) {
|
||||
if r#type == NSPasteboardTypeFileURL {
|
||||
let path = format!("/tmp/{}{}", TEMP_FILE_PREFIX, uuid::Uuid::new_v4().to_string());
|
||||
match std::fs::File::create(&path) {
|
||||
Ok(_) => {
|
||||
let url = format!("file:///{}", &path);
|
||||
item.setString_forType(&NSString::from_str(&url), &NSPasteboardTypeFileURL);
|
||||
let mut task_info = self.ivars().task_info.clone();
|
||||
task_info.source_path = path;
|
||||
self.ivars().tx.send(Ok(task_info)).ok();
|
||||
}
|
||||
Err(e) => {
|
||||
self.ivars().tx.send(Err(e)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #[method(pasteboardFinishedWithDataProvider:)]
|
||||
// unsafe fn pasteboardFinishedWithDataProvider(&self, pasteboard: &NSPasteboard) {
|
||||
// }
|
||||
}
|
||||
|
||||
unsafe impl PasteboardFileUrlProvider {}
|
||||
);
|
||||
|
||||
pub(super) fn create_pasteboard_file_url_provider(
|
||||
task_info: PasteObserverInfo,
|
||||
tx: Sender<Result<PasteObserverInfo>>,
|
||||
) -> Id<PasteboardFileUrlProvider> {
|
||||
let provider = PasteboardFileUrlProvider::alloc();
|
||||
let provider = provider.set_ivars(Ivars { task_info, tx });
|
||||
let provider: Id<PasteboardFileUrlProvider> = unsafe { msg_send_id![super(provider), init] };
|
||||
provider
|
||||
}
|
||||
14
libs/clipboard/src/platform/unix/macos/mod.rs
Normal file
14
libs/clipboard/src/platform/unix/macos/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod item_data_provider;
|
||||
mod paste_observer;
|
||||
mod paste_task;
|
||||
pub mod pasteboard_context;
|
||||
|
||||
pub fn should_handle_msg(msg: &crate::ClipboardFile) -> bool {
|
||||
matches!(
|
||||
msg,
|
||||
crate::ClipboardFile::FormatList { .. }
|
||||
| crate::ClipboardFile::FormatDataResponse { .. }
|
||||
| crate::ClipboardFile::FileContentsResponse { .. }
|
||||
| crate::ClipboardFile::TryEmpty
|
||||
)
|
||||
}
|
||||
BIN
libs/clipboard/src/platform/unix/macos/paste-files-macos.png
Normal file
BIN
libs/clipboard/src/platform/unix/macos/paste-files-macos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
179
libs/clipboard/src/platform/unix/macos/paste_observer.rs
Normal file
179
libs/clipboard/src/platform/unix/macos/paste_observer.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use super::pasteboard_context::PasteObserverInfo;
|
||||
use fsevent::{self, StreamFlags};
|
||||
use hbb_common::{bail, log, ResultType};
|
||||
use std::{
|
||||
sync::{
|
||||
mpsc::{channel, Receiver, RecvTimeoutError, Sender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
enum FseventControl {
|
||||
Start,
|
||||
Stop,
|
||||
Exit,
|
||||
}
|
||||
|
||||
struct FseventThreadInfo {
|
||||
tx: Sender<FseventControl>,
|
||||
handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct PasteObserver {
|
||||
exit: Arc<Mutex<bool>>,
|
||||
observer_info: Arc<Mutex<Option<PasteObserverInfo>>>,
|
||||
tx_handle_fsevent_thread: Option<FseventThreadInfo>,
|
||||
handle_observer_thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for PasteObserver {
|
||||
fn drop(&mut self) {
|
||||
*self.exit.lock().unwrap() = true;
|
||||
if let Some(handle_observer_thread) = self.handle_observer_thread.take() {
|
||||
handle_observer_thread.join().ok();
|
||||
}
|
||||
if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.take() {
|
||||
tx_handle_fsevent_thread.tx.send(FseventControl::Exit).ok();
|
||||
tx_handle_fsevent_thread.handle.join().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PasteObserver {
|
||||
const OBSERVE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
exit: Arc::new(Mutex::new(false)),
|
||||
observer_info: Default::default(),
|
||||
tx_handle_fsevent_thread: None,
|
||||
handle_observer_thread: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&mut self, cb_pasted: fn(&PasteObserverInfo) -> ()) -> ResultType<()> {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
bail!("No home dir is set, do not observe.");
|
||||
};
|
||||
|
||||
let (tx_observer, rx_observer) = channel::<fsevent::Event>();
|
||||
let handle_observer = Self::init_thread_observer(
|
||||
self.exit.clone(),
|
||||
self.observer_info.clone(),
|
||||
rx_observer,
|
||||
cb_pasted,
|
||||
);
|
||||
self.handle_observer_thread = Some(handle_observer);
|
||||
let (tx_control, rx_control) = channel::<FseventControl>();
|
||||
let handle_fsevent = Self::init_thread_fsevent(
|
||||
home_dir.to_string_lossy().to_string(),
|
||||
tx_observer,
|
||||
rx_control,
|
||||
);
|
||||
self.tx_handle_fsevent_thread = Some(FseventThreadInfo {
|
||||
tx: tx_control,
|
||||
handle: handle_fsevent,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_file_from_path(path: &String) -> String {
|
||||
let last_slash = path.rfind('/').or_else(|| path.rfind('\\'));
|
||||
match last_slash {
|
||||
Some(index) => path[index + 1..].to_string(),
|
||||
None => path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_thread_observer(
|
||||
exit: Arc<Mutex<bool>>,
|
||||
observer_info: Arc<Mutex<Option<PasteObserverInfo>>>,
|
||||
rx_observer: Receiver<fsevent::Event>,
|
||||
cb_pasted: fn(&PasteObserverInfo) -> (),
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || loop {
|
||||
match rx_observer.recv_timeout(Duration::from_millis(300)) {
|
||||
Ok(event) => {
|
||||
if (event.flag & StreamFlags::ITEM_CREATED) != StreamFlags::NONE
|
||||
&& (event.flag & StreamFlags::ITEM_REMOVED) == StreamFlags::NONE
|
||||
&& (event.flag & StreamFlags::IS_FILE) != StreamFlags::NONE
|
||||
{
|
||||
let source_file = observer_info
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|x| Self::get_file_from_path(&x.source_path));
|
||||
if let Some(source_file) = source_file {
|
||||
let file = Self::get_file_from_path(&event.path);
|
||||
if source_file == file {
|
||||
if let Some(observer_info) = observer_info.lock().unwrap().as_mut()
|
||||
{
|
||||
observer_info.target_path = event.path.clone();
|
||||
cb_pasted(observer_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if *(exit.lock().unwrap()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn new_fsevent(home_dir: String, tx_observer: Sender<fsevent::Event>) -> fsevent::FsEvent {
|
||||
let mut evt = fsevent::FsEvent::new(vec![home_dir.to_string()]);
|
||||
evt.observe_async(tx_observer).ok();
|
||||
evt
|
||||
}
|
||||
|
||||
fn init_thread_fsevent(
|
||||
home_dir: String,
|
||||
tx_observer: Sender<fsevent::Event>,
|
||||
rx_control: Receiver<FseventControl>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
log::debug!("fsevent observe dir: {}", &home_dir);
|
||||
thread::spawn(move || {
|
||||
let mut fsevent = None;
|
||||
loop {
|
||||
match rx_control.recv_timeout(Self::OBSERVE_TIMEOUT) {
|
||||
Ok(FseventControl::Start) => {
|
||||
if fsevent.is_none() {
|
||||
fsevent =
|
||||
Some(Self::new_fsevent(home_dir.clone(), tx_observer.clone()));
|
||||
}
|
||||
}
|
||||
Ok(FseventControl::Stop) | Err(RecvTimeoutError::Timeout) => {
|
||||
let _ = fsevent.as_mut().map(|e| e.shutdown_observe());
|
||||
fsevent = None;
|
||||
}
|
||||
Ok(FseventControl::Exit) | Err(RecvTimeoutError::Disconnected) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("fsevent thread exit");
|
||||
let _ = fsevent.as_mut().map(|e| e.shutdown_observe());
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start(&mut self, observer_info: PasteObserverInfo) {
|
||||
if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.as_ref() {
|
||||
self.observer_info.lock().unwrap().replace(observer_info);
|
||||
tx_handle_fsevent_thread.tx.send(FseventControl::Start).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(tx_handle_fsevent_thread) = &self.tx_handle_fsevent_thread {
|
||||
self.observer_info = Default::default();
|
||||
tx_handle_fsevent_thread.tx.send(FseventControl::Stop).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
639
libs/clipboard/src/platform/unix/macos/paste_task.rs
Normal file
639
libs/clipboard/src/platform/unix/macos/paste_task.rs
Normal file
@@ -0,0 +1,639 @@
|
||||
use crate::{
|
||||
platform::unix::{FileDescription, FileType, BLOCK_SIZE},
|
||||
send_data, ClipboardFile, CliprdrError, ProgressPercent,
|
||||
};
|
||||
use hbb_common::{allow_err, log, tokio::time::Instant};
|
||||
use std::{
|
||||
cmp::min,
|
||||
fs::{File, FileTimes},
|
||||
io::{BufWriter, Write},
|
||||
os::macos::fs::FileTimesExt,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
mpsc::{Receiver, RecvTimeoutError},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
const RECV_RETRY_TIMES: usize = 3;
|
||||
|
||||
const DOWNLOAD_EXTENSION: &str = "rddownload";
|
||||
const RECEIVE_WAIT_TIMEOUT: Duration = Duration::from_millis(5_000);
|
||||
|
||||
// https://stackoverflow.com/a/15112784/1926020
|
||||
// "1984-01-24 08:00:00 +0000"
|
||||
const TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED: u64 = 443779200;
|
||||
const ATTR_PROGRESS_FRACTION_COMPLETED: &str = "com.apple.progress.fractionCompleted";
|
||||
|
||||
pub struct FileContentsResponse {
|
||||
pub conn_id: i32,
|
||||
pub msg_flags: i32,
|
||||
pub stream_id: i32,
|
||||
pub requested_data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PasteTaskProgress {
|
||||
// Use list index to identify the file
|
||||
// `list_index` is also used as the stream id
|
||||
list_index: i32,
|
||||
offset: u64,
|
||||
total_size: u64,
|
||||
current_size: u64,
|
||||
last_sent_time: Instant,
|
||||
download_file_index: i32,
|
||||
download_file_size: u64,
|
||||
download_file_path: String,
|
||||
download_file_current_size: u64,
|
||||
file_handle: Option<BufWriter<File>>,
|
||||
error: Option<CliprdrError>,
|
||||
is_canceled: bool,
|
||||
}
|
||||
|
||||
struct PasteTaskHandle {
|
||||
progress: PasteTaskProgress,
|
||||
target_dir: PathBuf,
|
||||
files: Vec<FileDescription>,
|
||||
}
|
||||
|
||||
pub struct PasteTask {
|
||||
exit: Arc<Mutex<bool>>,
|
||||
handle: Arc<Mutex<Option<PasteTaskHandle>>>,
|
||||
handle_worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for PasteTask {
|
||||
fn drop(&mut self) {
|
||||
*self.exit.lock().unwrap() = true;
|
||||
if let Some(handle_worker) = self.handle_worker.take() {
|
||||
handle_worker.join().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PasteTask {
|
||||
const INVALID_FILE_INDEX: i32 = -1;
|
||||
|
||||
pub fn new(rx_file_contents: Receiver<FileContentsResponse>) -> Self {
|
||||
let exit = Arc::new(Mutex::new(false));
|
||||
let handle = Arc::new(Mutex::new(None));
|
||||
let handle_worker =
|
||||
Self::init_worker_thread(exit.clone(), handle.clone(), rx_file_contents);
|
||||
Self {
|
||||
handle,
|
||||
exit,
|
||||
handle_worker: Some(handle_worker),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, target_dir: PathBuf, files: Vec<FileDescription>) {
|
||||
let mut task_lock = self.handle.lock().unwrap();
|
||||
if task_lock
|
||||
.as_ref()
|
||||
.map(|x| !x.is_finished())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
log::error!("Previous paste task is not finished, ignore new request.");
|
||||
return;
|
||||
}
|
||||
let total_size = files.iter().map(|f| f.size).sum();
|
||||
let mut task_handle = PasteTaskHandle {
|
||||
progress: PasteTaskProgress {
|
||||
list_index: -1,
|
||||
offset: 0,
|
||||
total_size,
|
||||
current_size: 0,
|
||||
last_sent_time: Instant::now(),
|
||||
download_file_index: Self::INVALID_FILE_INDEX,
|
||||
download_file_size: 0,
|
||||
download_file_path: "".to_owned(),
|
||||
download_file_current_size: 0,
|
||||
file_handle: None,
|
||||
error: None,
|
||||
is_canceled: false,
|
||||
},
|
||||
target_dir,
|
||||
files,
|
||||
};
|
||||
task_handle.update_next(0).ok();
|
||||
if task_handle.is_finished() {
|
||||
task_handle.on_finished();
|
||||
} else {
|
||||
if let Err(e) = task_handle.send_file_contents_request() {
|
||||
log::error!("Failed to send file contents request, error: {}", &e);
|
||||
task_handle.on_error(e);
|
||||
}
|
||||
}
|
||||
*task_lock = Some(task_handle);
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
let mut task_handle = self.handle.lock().unwrap();
|
||||
if let Some(task_handle) = task_handle.as_mut() {
|
||||
task_handle.progress.is_canceled = true;
|
||||
task_handle.on_cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
fn init_worker_thread(
|
||||
exit: Arc<Mutex<bool>>,
|
||||
handle: Arc<Mutex<Option<PasteTaskHandle>>>,
|
||||
rx_file_contents: Receiver<FileContentsResponse>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
let mut retry_count = 0;
|
||||
loop {
|
||||
if *exit.lock().unwrap() {
|
||||
break;
|
||||
}
|
||||
|
||||
match rx_file_contents.recv_timeout(Duration::from_millis(300)) {
|
||||
Ok(file_contents) => {
|
||||
let mut task_lock = handle.lock().unwrap();
|
||||
let Some(task_handle) = task_lock.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if task_handle.is_finished() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_contents.stream_id != task_handle.progress.list_index {
|
||||
// ignore invalid stream id
|
||||
continue;
|
||||
} else if file_contents.msg_flags != 0x01 {
|
||||
retry_count += 1;
|
||||
if retry_count > RECV_RETRY_TIMES {
|
||||
task_handle.progress.error = Some(CliprdrError::InvalidRequest {
|
||||
description: format!(
|
||||
"Failed to read file contents, stream id: {}, msg_flags: {}",
|
||||
file_contents.stream_id,
|
||||
file_contents.msg_flags
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let resp_list_index = file_contents.stream_id;
|
||||
let Some(file) = &task_handle.files.get(resp_list_index as usize)
|
||||
else {
|
||||
// unreachable
|
||||
// Because `task_handle.progress.list_index >= task_handle.files.len()` should always be false
|
||||
log::warn!(
|
||||
"Invalid response list index: {}, file length: {}",
|
||||
resp_list_index,
|
||||
task_handle.files.len()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if file.conn_id != file_contents.conn_id {
|
||||
// unreachable
|
||||
// We still add log here to make sure we can see the error message when it happens.
|
||||
log::error!(
|
||||
"Invalid response conn id: {}, expected: {}",
|
||||
file_contents.conn_id,
|
||||
file.conn_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = task_handle.handle_file_contents_response(file_contents)
|
||||
{
|
||||
log::error!("Failed to handle file contents response: {}", &e);
|
||||
task_handle.on_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if !task_handle.is_finished() {
|
||||
if let Err(e) = task_handle.send_file_contents_request() {
|
||||
log::error!("Failed to send file contents request: {}", &e);
|
||||
task_handle.on_error(e);
|
||||
}
|
||||
} else {
|
||||
retry_count = 0;
|
||||
task_handle.on_finished();
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
let mut task_lock = handle.lock().unwrap();
|
||||
if let Some(task_handle) = task_lock.as_mut() {
|
||||
if task_handle.check_receive_timemout() {
|
||||
retry_count = 0;
|
||||
task_handle.on_finished();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.handle
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|handle| handle.is_finished())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn progress_percent(&self) -> Option<ProgressPercent> {
|
||||
self.handle
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|handle| handle.progress_percent())
|
||||
}
|
||||
}
|
||||
|
||||
impl PasteTaskHandle {
|
||||
fn update_next(&mut self, size: u64) -> Result<(), CliprdrError> {
|
||||
if self.is_finished() {
|
||||
return Ok(());
|
||||
}
|
||||
self.progress.current_size += size;
|
||||
|
||||
let is_start = self.progress.list_index == -1;
|
||||
if is_start || (self.progress.offset + size) >= self.progress.download_file_size {
|
||||
if !is_start {
|
||||
self.on_done();
|
||||
}
|
||||
for i in (self.progress.list_index + 1)..self.files.len() as i32 {
|
||||
let Some(file_desc) = self.files.get(i as usize) else {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: format!("Invalid file index: {}", i),
|
||||
});
|
||||
};
|
||||
match file_desc.kind {
|
||||
FileType::File => {
|
||||
if file_desc.size == 0 {
|
||||
if let Some(new_file_path) =
|
||||
Self::get_new_filename(&self.target_dir, file_desc)
|
||||
{
|
||||
if let Ok(f) = std::fs::File::create(&new_file_path) {
|
||||
f.set_len(0).ok();
|
||||
Self::set_file_metadata(&f, file_desc);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
self.progress.list_index = i;
|
||||
self.progress.offset = 0;
|
||||
self.open_new_writer()?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
FileType::Directory => {
|
||||
let path = self.target_dir.join(&file_desc.name);
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path).ok();
|
||||
}
|
||||
}
|
||||
FileType::Symlink => {
|
||||
// to-do: handle symlink
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.progress.offset += size;
|
||||
self.progress.download_file_current_size += size;
|
||||
self.update_progress_completed(None);
|
||||
}
|
||||
if self.progress.file_handle.is_none() {
|
||||
self.progress.list_index = self.files.len() as i32;
|
||||
self.progress.offset = 0;
|
||||
self.progress.download_file_size = 0;
|
||||
self.progress.download_file_current_size = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_progress_completed(&self) {
|
||||
if let Some(file) = self.progress.file_handle.as_ref() {
|
||||
let creation_time =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED);
|
||||
file.get_ref()
|
||||
.set_times(FileTimes::new().set_created(creation_time))
|
||||
.ok();
|
||||
xattr::set(
|
||||
&self.progress.download_file_path,
|
||||
ATTR_PROGRESS_FRACTION_COMPLETED,
|
||||
"0.0".as_bytes(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_progress_completed(&mut self, fraction_completed: Option<f64>) {
|
||||
let fraction_completed = fraction_completed.unwrap_or_else(|| {
|
||||
let current_size = self.progress.download_file_current_size as f64;
|
||||
let total_size = self.progress.download_file_size as f64;
|
||||
if total_size > 0.0 {
|
||||
current_size / total_size
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
});
|
||||
xattr::set(
|
||||
&self.progress.download_file_path,
|
||||
ATTR_PROGRESS_FRACTION_COMPLETED,
|
||||
&fraction_completed.to_string().as_bytes(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn remove_progress_completed(path: &str) {
|
||||
if !path.is_empty() {
|
||||
xattr::remove(path, ATTR_PROGRESS_FRACTION_COMPLETED).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_new_writer(&mut self) -> Result<(), CliprdrError> {
|
||||
let Some(file) = &self.files.get(self.progress.list_index as usize) else {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: format!(
|
||||
"Invalid file index: {}, file count: {}",
|
||||
self.progress.list_index,
|
||||
self.files.len()
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
let original_file_path = self
|
||||
.target_dir
|
||||
.join(&file.name)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let Some(download_file_path) = Self::get_first_filename(
|
||||
format!("{}.{}", original_file_path, DOWNLOAD_EXTENSION),
|
||||
file.kind,
|
||||
) else {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: format!("Failed to get download file path: {}", original_file_path),
|
||||
});
|
||||
};
|
||||
let Some(download_path_parent) = Path::new(&download_file_path).parent() else {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: format!(
|
||||
"Failed to get parent of the download file path: {}",
|
||||
original_file_path
|
||||
),
|
||||
});
|
||||
};
|
||||
if !download_path_parent.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(download_path_parent) {
|
||||
return Err(CliprdrError::FileError {
|
||||
path: download_path_parent.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
match std::fs::File::create(&download_file_path) {
|
||||
Ok(handle) => {
|
||||
let writer = BufWriter::with_capacity(BLOCK_SIZE as usize * 2, handle);
|
||||
self.progress.download_file_index = self.progress.list_index;
|
||||
self.progress.download_file_size = file.size;
|
||||
self.progress.download_file_path = download_file_path;
|
||||
self.progress.download_file_current_size = 0;
|
||||
self.progress.file_handle = Some(writer);
|
||||
self.start_progress_completed();
|
||||
}
|
||||
Err(e) => {
|
||||
self.progress.error = Some(CliprdrError::FileError {
|
||||
path: download_file_path,
|
||||
err: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_first_filename(path: String, r#type: FileType) -> Option<String> {
|
||||
let p = Path::new(&path);
|
||||
if !p.exists() {
|
||||
return Some(path);
|
||||
} else {
|
||||
for i in 1..9999999 {
|
||||
let new_path = match r#type {
|
||||
FileType::File => {
|
||||
if let Some(ext) = p.extension() {
|
||||
let new_name = format!(
|
||||
"{}-{}.{}",
|
||||
p.file_stem().unwrap_or_default().to_string_lossy(),
|
||||
i,
|
||||
ext.to_string_lossy()
|
||||
);
|
||||
p.with_file_name(new_name).to_string_lossy().to_string()
|
||||
} else {
|
||||
format!("{} ({})", path, i)
|
||||
}
|
||||
}
|
||||
FileType::Directory => format!("{} ({})", path, i),
|
||||
FileType::Symlink => {
|
||||
// to-do: handle symlink
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !Path::new(&new_path).exists() {
|
||||
return Some(new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// unreachable
|
||||
None
|
||||
}
|
||||
|
||||
fn progress_percent(&self) -> ProgressPercent {
|
||||
let percent = self.progress.current_size as f64 / self.progress.total_size as f64;
|
||||
ProgressPercent {
|
||||
percent,
|
||||
is_canceled: self.progress.is_canceled,
|
||||
is_failed: self.progress.error.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
self.progress.is_canceled
|
||||
|| self.progress.error.is_some()
|
||||
|| self.progress.list_index >= self.files.len() as i32
|
||||
}
|
||||
|
||||
fn check_receive_timemout(&mut self) -> bool {
|
||||
if !self.is_finished() {
|
||||
if self.progress.last_sent_time.elapsed() > RECEIVE_WAIT_TIMEOUT {
|
||||
self.progress.error = Some(CliprdrError::InvalidRequest {
|
||||
description: "Failed to read file contents".to_string(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn on_finished(&mut self) {
|
||||
if self.progress.error.is_some() {
|
||||
self.on_cancelled();
|
||||
} else {
|
||||
self.on_done();
|
||||
}
|
||||
if self.progress.current_size != self.progress.total_size {
|
||||
self.progress.error = Some(CliprdrError::InvalidRequest {
|
||||
description: "Failed to download all files".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn on_error(&mut self, error: CliprdrError) {
|
||||
self.progress.error = Some(error);
|
||||
self.on_cancelled();
|
||||
}
|
||||
|
||||
fn on_cancelled(&mut self) {
|
||||
self.progress.file_handle = None;
|
||||
std::fs::remove_file(&self.progress.download_file_path).ok();
|
||||
}
|
||||
|
||||
fn on_done(&mut self) {
|
||||
self.update_progress_completed(Some(1.0));
|
||||
Self::remove_progress_completed(&self.progress.download_file_path);
|
||||
|
||||
let Some(file) = self.progress.file_handle.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if self.progress.download_file_index == PasteTask::INVALID_FILE_INDEX {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = file.flush() {
|
||||
log::error!("Failed to flush file: {:?}", e);
|
||||
}
|
||||
self.progress.file_handle = None;
|
||||
|
||||
let Some(file_desc) = self.files.get(self.progress.download_file_index as usize) else {
|
||||
// unreachable
|
||||
log::error!(
|
||||
"Failed to get file description: {}",
|
||||
self.progress.download_file_index
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(rename_to_path) = Self::get_new_filename(&self.target_dir, file_desc) else {
|
||||
return;
|
||||
};
|
||||
match std::fs::rename(&self.progress.download_file_path, &rename_to_path) {
|
||||
Ok(_) => Self::set_file_metadata2(&rename_to_path, file_desc),
|
||||
Err(e) => {
|
||||
log::error!("Failed to rename file: {:?}", e);
|
||||
}
|
||||
}
|
||||
self.progress.download_file_path = "".to_owned();
|
||||
self.progress.download_file_index = PasteTask::INVALID_FILE_INDEX;
|
||||
}
|
||||
|
||||
fn get_new_filename(target_dir: &PathBuf, file_desc: &FileDescription) -> Option<String> {
|
||||
let mut rename_to_path = target_dir
|
||||
.join(&file_desc.name)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if Path::new(&rename_to_path).exists() {
|
||||
let Some(new_path) = Self::get_first_filename(rename_to_path.clone(), file_desc.kind)
|
||||
else {
|
||||
log::error!("Failed to get new file name: {}", &rename_to_path);
|
||||
return None;
|
||||
};
|
||||
rename_to_path = new_path;
|
||||
}
|
||||
Some(rename_to_path)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn set_file_metadata(f: &File, file_desc: &FileDescription) {
|
||||
let times = FileTimes::new()
|
||||
.set_accessed(file_desc.atime)
|
||||
.set_modified(file_desc.last_modified)
|
||||
.set_created(file_desc.creation_time);
|
||||
f.set_times(times).ok();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn set_file_metadata2(path: &str, file_desc: &FileDescription) {
|
||||
let times = FileTimes::new()
|
||||
.set_accessed(file_desc.atime)
|
||||
.set_modified(file_desc.last_modified)
|
||||
.set_created(file_desc.creation_time);
|
||||
File::options()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map(|f| f.set_times(times))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn send_file_contents_request(&mut self) -> Result<(), CliprdrError> {
|
||||
if self.is_finished() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream_id = self.progress.list_index;
|
||||
let list_index = self.progress.list_index;
|
||||
let Some(file) = &self.files.get(list_index as usize) else {
|
||||
// unreachable
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: format!("Invalid file index: {}", list_index),
|
||||
});
|
||||
};
|
||||
let cb_requested = min(BLOCK_SIZE as u64, file.size - self.progress.offset);
|
||||
let conn_id = file.conn_id;
|
||||
|
||||
let (n_position_high, n_position_low) = (
|
||||
(self.progress.offset >> 32) as i32,
|
||||
(self.progress.offset & (u32::MAX as u64)) as i32,
|
||||
);
|
||||
let request = ClipboardFile::FileContentsRequest {
|
||||
stream_id,
|
||||
list_index,
|
||||
dw_flags: 2,
|
||||
n_position_low,
|
||||
n_position_high,
|
||||
cb_requested: cb_requested as _,
|
||||
have_clip_data_id: false,
|
||||
clip_data_id: 0,
|
||||
};
|
||||
allow_err!(send_data(conn_id, request));
|
||||
self.progress.last_sent_time = Instant::now();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_file_contents_response(
|
||||
&mut self,
|
||||
file_contents: FileContentsResponse,
|
||||
) -> Result<(), CliprdrError> {
|
||||
if let Some(file) = self.progress.file_handle.as_mut() {
|
||||
let data = file_contents.requested_data.as_slice();
|
||||
let mut write_len = 0;
|
||||
while write_len < data.len() {
|
||||
match file.write(&data[write_len..]) {
|
||||
Ok(len) => {
|
||||
write_len += len;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CliprdrError::FileError {
|
||||
path: self.progress.download_file_path.clone(),
|
||||
err: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.update_next(write_len as _)?;
|
||||
} else {
|
||||
return Err(CliprdrError::FileError {
|
||||
path: self.progress.download_file_path.clone(),
|
||||
err: std::io::Error::new(std::io::ErrorKind::NotFound, "file handle is not opened"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
460
libs/clipboard/src/platform/unix/macos/pasteboard_context.rs
Normal file
460
libs/clipboard/src/platform/unix/macos/pasteboard_context.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use super::{
|
||||
item_data_provider::create_pasteboard_file_url_provider,
|
||||
paste_observer::PasteObserver,
|
||||
paste_task::{FileContentsResponse, PasteTask},
|
||||
};
|
||||
use crate::{
|
||||
platform::unix::{
|
||||
filetype::FileDescription, FILECONTENTS_FORMAT_NAME, FILEDESCRIPTORW_FORMAT_NAME,
|
||||
},
|
||||
send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, ProgressPercent,
|
||||
};
|
||||
use hbb_common::{allow_err, bail, log, ResultType};
|
||||
use objc2::{msg_send_id, rc::autoreleasepool, rc::Id, runtime::ProtocolObject, ClassType};
|
||||
use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL};
|
||||
use objc2_foundation::{NSArray, NSString};
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
sync::{
|
||||
mpsc::{channel, Receiver, RecvTimeoutError, Sender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref PASTE_OBSERVER_INFO: Arc<Mutex<Option<PasteObserverInfo>>> = Default::default();
|
||||
}
|
||||
|
||||
pub const TEMP_FILE_PREFIX: &str = ".rustdesk_";
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq)]
|
||||
pub(super) struct PasteObserverInfo {
|
||||
pub file_descriptor_id: i32,
|
||||
pub conn_id: i32,
|
||||
pub source_path: String,
|
||||
pub target_path: String,
|
||||
}
|
||||
|
||||
impl PasteObserverInfo {
|
||||
fn exit_msg() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextInfo {
|
||||
tx: Sender<io::Result<PasteObserverInfo>>,
|
||||
handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct PasteboardContext {
|
||||
pasteboard: Id<NSPasteboard>,
|
||||
observer: Arc<Mutex<PasteObserver>>,
|
||||
tx_handle: Option<ContextInfo>,
|
||||
tx_remove_file: Option<Sender<String>>,
|
||||
remove_file_handle: Option<thread::JoinHandle<()>>,
|
||||
tx_paste_task: Sender<FileContentsResponse>,
|
||||
paste_task: Arc<Mutex<PasteTask>>,
|
||||
}
|
||||
|
||||
unsafe impl Send for PasteboardContext {}
|
||||
unsafe impl Sync for PasteboardContext {}
|
||||
|
||||
impl Drop for PasteboardContext {
|
||||
fn drop(&mut self) {
|
||||
self.observer.lock().unwrap().stop();
|
||||
if let Some(tx_handle) = self.tx_handle.take() {
|
||||
if tx_handle.tx.send(Ok(PasteObserverInfo::exit_msg())).is_ok() {
|
||||
tx_handle.handle.join().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CliprdrServiceContext for PasteboardContext {
|
||||
fn set_is_stopped(&mut self) -> Result<(), CliprdrError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_clipboard(&mut self, conn_id: i32) -> Result<bool, CliprdrError> {
|
||||
Ok(self.empty_clipboard_(conn_id))
|
||||
}
|
||||
|
||||
fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> {
|
||||
self.server_clip_file_(conn_id, msg)
|
||||
}
|
||||
|
||||
fn get_progress_percent(&self) -> Option<ProgressPercent> {
|
||||
self.paste_task.lock().unwrap().progress_percent()
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.paste_task.lock().unwrap().cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl PasteboardContext {
|
||||
fn init(&mut self) {
|
||||
let (tx_remove_file, rx_remove_file) = channel();
|
||||
let handle_remove_file = Self::init_thread_remove_file(rx_remove_file);
|
||||
self.tx_remove_file = Some(tx_remove_file.clone());
|
||||
self.remove_file_handle = Some(handle_remove_file);
|
||||
|
||||
let (tx, rx) = channel();
|
||||
let observer: Arc<Mutex<PasteObserver>> = self.observer.clone();
|
||||
let handle = Self::init_thread_observer(tx_remove_file, rx, observer);
|
||||
self.tx_handle = Some(ContextInfo { tx, handle });
|
||||
}
|
||||
|
||||
fn init_thread_observer(
|
||||
tx_remove_file: Sender<String>,
|
||||
rx: Receiver<io::Result<PasteObserverInfo>>,
|
||||
observer: Arc<Mutex<PasteObserver>>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
let exit_msg = PasteObserverInfo::exit_msg();
|
||||
thread::spawn(move || loop {
|
||||
match rx.recv() {
|
||||
Ok(Ok(task_info)) => {
|
||||
if task_info == exit_msg {
|
||||
log::debug!("pasteboard item data provider: exit");
|
||||
break;
|
||||
}
|
||||
tx_remove_file.send(task_info.source_path.clone()).ok();
|
||||
observer.lock().unwrap().start(task_info);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("pasteboard item data provider, inner error: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("pasteboard item data provider, error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn init_thread_remove_file(rx: Receiver<String>) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
let mut cur_file: Option<String> = None;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_secs(30)) {
|
||||
Ok(path) => {
|
||||
if let Some(file) = cur_file.take() {
|
||||
if !file.is_empty() {
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
if !path.is_empty() {
|
||||
cur_file = Some(path);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(file) = cur_file.take() {
|
||||
if !file.is_empty() {
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
if e == RecvTimeoutError::Disconnected {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Just removing the file can also make paste option in the context menu disappear.
|
||||
fn empty_clipboard_(&mut self, _conn_id: i32) -> bool {
|
||||
self.tx_remove_file
|
||||
.as_ref()
|
||||
.map(|tx| tx.send("".to_string()).ok());
|
||||
true
|
||||
}
|
||||
|
||||
fn temp_files_count() -> usize {
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = std::fs::read_dir("/tmp") {
|
||||
for entry in entries {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(file_name) = path.file_name() {
|
||||
if let Some(file_name_str) = file_name.to_str() {
|
||||
if file_name_str.starts_with(TEMP_FILE_PREFIX) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn server_clip_file_(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> {
|
||||
match msg {
|
||||
ClipboardFile::FormatList { format_list } => {
|
||||
let temp_files = Self::temp_files_count();
|
||||
if temp_files >= 3 {
|
||||
// The temp files should be 0 or 1 in normal case.
|
||||
// We should not continue to paste files if there are more than 3 temp files.
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: format!(
|
||||
"too many temp files, current: {}, limit: {}",
|
||||
temp_files, 3
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let task_lock = self.paste_task.lock().unwrap();
|
||||
if !task_lock.is_finished() {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "previous file paste task is not finished".to_string(),
|
||||
});
|
||||
}
|
||||
self.handle_format_list(conn_id, format_list)?;
|
||||
}
|
||||
ClipboardFile::FormatDataResponse {
|
||||
msg_flags,
|
||||
format_data,
|
||||
} => {
|
||||
self.handle_format_data_response(conn_id, msg_flags, format_data)?;
|
||||
}
|
||||
ClipboardFile::FileContentsResponse {
|
||||
msg_flags,
|
||||
stream_id,
|
||||
requested_data,
|
||||
} => {
|
||||
self.handle_file_contents_response(conn_id, msg_flags, stream_id, requested_data)?;
|
||||
}
|
||||
ClipboardFile::TryEmpty => self.handle_try_empty(conn_id),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_format_list(
|
||||
&self,
|
||||
conn_id: i32,
|
||||
format_list: Vec<(i32, String)>,
|
||||
) -> Result<(), CliprdrError> {
|
||||
if let Some(tx_handle) = self.tx_handle.as_ref() {
|
||||
if !format_list
|
||||
.iter()
|
||||
.find(|(_, name)| name == FILECONTENTS_FORMAT_NAME)
|
||||
.map(|(id, _)| *id)
|
||||
.is_some()
|
||||
{
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "no file contents format found".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(file_descriptor_id) = format_list
|
||||
.iter()
|
||||
.find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME)
|
||||
.map(|(id, _)| *id)
|
||||
else {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "no file descriptor format found".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
autoreleasepool(|_| self.set_clipboard_item(tx_handle, conn_id, file_descriptor_id))?;
|
||||
} else {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "pasteboard context is not inited".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_clipboard_item(
|
||||
&self,
|
||||
tx_handle: &ContextInfo,
|
||||
conn_id: i32,
|
||||
file_descriptor_id: i32,
|
||||
) -> Result<(), CliprdrError> {
|
||||
let tx = tx_handle.tx.clone();
|
||||
let provider = create_pasteboard_file_url_provider(
|
||||
PasteObserverInfo {
|
||||
file_descriptor_id,
|
||||
conn_id,
|
||||
source_path: "".to_string(),
|
||||
target_path: "".to_string(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
unsafe {
|
||||
let types = NSArray::from_vec(vec![NSString::from_str(
|
||||
&NSPasteboardTypeFileURL.to_string(),
|
||||
)]);
|
||||
let item = objc2_app_kit::NSPasteboardItem::new();
|
||||
item.setDataProvider_forTypes(&ProtocolObject::from_id(provider), &types);
|
||||
self.pasteboard.clearContents();
|
||||
if !self
|
||||
.pasteboard
|
||||
.writeObjects(&Id::cast(NSArray::from_vec(vec![item])))
|
||||
{
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "failed to write objects".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_format_data_response(
|
||||
&self,
|
||||
conn_id: i32,
|
||||
msg_flags: i32,
|
||||
format_data: Vec<u8>,
|
||||
) -> Result<(), CliprdrError> {
|
||||
log::debug!("handle format data response, msg_flags: {msg_flags}");
|
||||
if msg_flags != 0x1 {
|
||||
// return failure message?
|
||||
}
|
||||
|
||||
let mut task_lock = self.paste_task.lock().unwrap();
|
||||
let target_dir = PASTE_OBSERVER_INFO
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|task| task.target_path.clone());
|
||||
// unreachable in normal case
|
||||
let Some(target_dir) = target_dir.as_ref().map(|d| Path::new(d).parent()).flatten() else {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "failed to get parent path".to_string(),
|
||||
});
|
||||
};
|
||||
// unreachable in normal case
|
||||
if !target_dir.exists() {
|
||||
return Err(CliprdrError::CommonError {
|
||||
description: "target path does not exist".to_string(),
|
||||
});
|
||||
}
|
||||
let target_dir = target_dir.to_owned();
|
||||
match FileDescription::parse_file_descriptors(format_data, conn_id) {
|
||||
Ok(files) => {
|
||||
task_lock.start(target_dir, files);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
PASTE_OBSERVER_INFO
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(PasteObserverInfo::default());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_contents_response(
|
||||
&self,
|
||||
conn_id: i32,
|
||||
msg_flags: i32,
|
||||
stream_id: i32,
|
||||
requested_data: Vec<u8>,
|
||||
) -> Result<(), CliprdrError> {
|
||||
log::debug!("handle file contents response");
|
||||
self.tx_paste_task
|
||||
.send(FileContentsResponse {
|
||||
conn_id,
|
||||
msg_flags,
|
||||
stream_id,
|
||||
requested_data,
|
||||
})
|
||||
.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_try_empty(&mut self, conn_id: i32) {
|
||||
log::debug!("empty_clipboard called");
|
||||
let ret = self.empty_clipboard_(conn_id);
|
||||
log::debug!(
|
||||
"empty_clipboard called, conn_id {}, return {}",
|
||||
conn_id,
|
||||
ret
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste_result(task_info: &PasteObserverInfo) {
|
||||
log::info!(
|
||||
"file {} is pasted to {}",
|
||||
&task_info.source_path,
|
||||
&task_info.target_path
|
||||
);
|
||||
if Path::new(&task_info.target_path).parent().is_none() {
|
||||
log::error!(
|
||||
"failed to get parent path of {}, no need to perform pasting",
|
||||
&task_info.target_path
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
PASTE_OBSERVER_INFO
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(task_info.clone());
|
||||
// to-do: add a timeout to clear data in `PASTE_OBSERVER_INFO`.
|
||||
std::fs::remove_file(&task_info.source_path).ok();
|
||||
std::fs::remove_file(&task_info.target_path).ok();
|
||||
let data = ClipboardFile::FormatDataRequest {
|
||||
requested_format_id: task_info.file_descriptor_id,
|
||||
};
|
||||
allow_err!(send_data(task_info.conn_id as _, data));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn create_pasteboard_context() -> ResultType<Box<PasteboardContext>> {
|
||||
let pasteboard: Option<Id<NSPasteboard>> =
|
||||
unsafe { msg_send_id![NSPasteboard::class(), generalPasteboard] };
|
||||
let Some(pasteboard) = pasteboard else {
|
||||
bail!("failed to get general pasteboard");
|
||||
};
|
||||
let mut observer = PasteObserver::new();
|
||||
observer.init(handle_paste_result)?;
|
||||
let (tx, rx) = channel();
|
||||
let mut context = Box::new(PasteboardContext {
|
||||
pasteboard,
|
||||
observer: Arc::new(Mutex::new(observer)),
|
||||
tx_handle: None,
|
||||
tx_remove_file: None,
|
||||
remove_file_handle: None,
|
||||
tx_paste_task: tx,
|
||||
paste_task: Arc::new(Mutex::new(PasteTask::new(rx))),
|
||||
});
|
||||
context.init();
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_temp_files_count() {
|
||||
let mut c = super::PasteboardContext::temp_files_count();
|
||||
|
||||
let mut created_files = vec![];
|
||||
for _ in 0..10 {
|
||||
let path = format!(
|
||||
"/tmp/{}{}",
|
||||
super::TEMP_FILE_PREFIX,
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
);
|
||||
if std::fs::File::create(&path).is_ok() {
|
||||
created_files.push(path);
|
||||
c += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(c, super::PasteboardContext::temp_files_count());
|
||||
|
||||
// Clean up the created files.
|
||||
for file in created_files {
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ use dashmap::DashMap;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
mod filetype;
|
||||
pub use filetype::{FileDescription, FileType};
|
||||
/// use FUSE for file pasting on these platforms
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod fuse;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
pub mod local_file;
|
||||
pub mod serv_files;
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
#![allow(deref_nullptr)]
|
||||
|
||||
use crate::{
|
||||
send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext, ResultType,
|
||||
ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL,
|
||||
send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext,
|
||||
ProgressPercent, ResultType, ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG,
|
||||
ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL,
|
||||
};
|
||||
use hbb_common::{allow_err, log};
|
||||
use std::{
|
||||
@@ -602,6 +603,12 @@ impl CliprdrServiceContext for CliprdrClientContext {
|
||||
let ret = server_clip_file(self, conn_id, msg);
|
||||
ret_to_result(ret)
|
||||
}
|
||||
|
||||
fn get_progress_percent(&self) -> Option<ProgressPercent> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {}
|
||||
}
|
||||
|
||||
fn ret_to_result(ret: u32) -> Result<(), CliprdrError> {
|
||||
@@ -745,7 +752,11 @@ pub fn server_clip_file(
|
||||
ClipboardFile::TryEmpty => {
|
||||
log::debug!("empty_clipboard called");
|
||||
let ret = empty_clipboard(context, conn_id);
|
||||
log::debug!("empty_clipboard called, conn_id {}, return {}", conn_id, ret);
|
||||
log::debug!(
|
||||
"empty_clipboard called, conn_id {}, return {}",
|
||||
conn_id,
|
||||
ret
|
||||
);
|
||||
}
|
||||
}
|
||||
ret
|
||||
|
||||
Submodule libs/hbb_common updated: 16900b9b06...81b932b7bf
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.3.8"
|
||||
version = "1.3.9"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
|
||||
11
libs/remote_printer/Cargo.toml
Normal file
11
libs/remote_printer/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "remote_printer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hbb_common = { version = "0.1.0", path = "../hbb_common" }
|
||||
winapi = { version = "0.3" }
|
||||
windows-strings = "0.3.1"
|
||||
34
libs/remote_printer/src/lib.rs
Normal file
34
libs/remote_printer/src/lib.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
mod setup;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use setup::{
|
||||
is_rd_printer_installed,
|
||||
setup::{install_update_printer, uninstall_printer},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const RD_DRIVER_INF_PATH: &str = "drivers/RustDeskPrinterDriver/RustDeskPrinterDriver.inf";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_printer_name(app_name: &str) -> Vec<u16> {
|
||||
format!("{} Printer", app_name)
|
||||
.encode_utf16()
|
||||
.chain(Some(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_driver_name() -> Vec<u16> {
|
||||
"RustDesk v4 Printer Driver"
|
||||
.encode_utf16()
|
||||
.chain(Some(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_port_name(app_name: &str) -> Vec<u16> {
|
||||
format!("{} Printer", app_name)
|
||||
.encode_utf16()
|
||||
.chain(Some(0))
|
||||
.collect()
|
||||
}
|
||||
202
libs/remote_printer/src/setup/driver.rs
Normal file
202
libs/remote_printer/src/setup/driver.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use super::{common_enum, get_wstr_bytes, is_name_equal};
|
||||
use hbb_common::{bail, log, ResultType};
|
||||
use std::{io, ptr::null_mut, time::Duration};
|
||||
use winapi::{
|
||||
shared::{
|
||||
minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD, MAX_PATH},
|
||||
ntdef::{DWORDLONG, LPCWSTR},
|
||||
winerror::{ERROR_UNKNOWN_PRINTER_DRIVER, S_OK},
|
||||
},
|
||||
um::{
|
||||
winspool::{
|
||||
DeletePrinterDriverExW, DeletePrinterDriverPackageW, EnumPrinterDriversW,
|
||||
InstallPrinterDriverFromPackageW, UploadPrinterDriverPackageW, DPD_DELETE_ALL_FILES,
|
||||
DRIVER_INFO_6W, DRIVER_INFO_8W, IPDFP_COPY_ALL_FILES, UPDP_SILENT_UPLOAD,
|
||||
UPDP_UPLOAD_ALWAYS,
|
||||
},
|
||||
winuser::GetForegroundWindow,
|
||||
},
|
||||
};
|
||||
use windows_strings::PCWSTR;
|
||||
|
||||
const HRESULT_ERR_ELEMENT_NOT_FOUND: u32 = 0x80070490;
|
||||
|
||||
fn enum_printer_driver(
|
||||
level: DWORD,
|
||||
p_driver_info: LPBYTE,
|
||||
cb_buf: DWORD,
|
||||
pcb_needed: LPDWORD,
|
||||
pc_returned: LPDWORD,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinterdrivers
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
EnumPrinterDriversW(
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
level,
|
||||
p_driver_info,
|
||||
cb_buf,
|
||||
pcb_needed,
|
||||
pc_returned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_installed_driver_version(name: &PCWSTR) -> ResultType<Option<DWORDLONG>> {
|
||||
common_enum(
|
||||
"EnumPrinterDriversW",
|
||||
enum_printer_driver,
|
||||
6,
|
||||
|info: &DRIVER_INFO_6W| {
|
||||
if is_name_equal(name, info.pName) {
|
||||
Some(info.dwlDriverVersion)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|| None,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_inf(name: &PCWSTR) -> ResultType<Vec<u16>> {
|
||||
let r = common_enum(
|
||||
"EnumPrinterDriversW",
|
||||
enum_printer_driver,
|
||||
8,
|
||||
|info: &DRIVER_INFO_8W| {
|
||||
if is_name_equal(name, info.pName) {
|
||||
Some(get_wstr_bytes(info.pszInfPath))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|| None,
|
||||
)?;
|
||||
Ok(r.unwrap_or(vec![]))
|
||||
}
|
||||
|
||||
fn delete_printer_driver(name: &PCWSTR) -> ResultType<()> {
|
||||
unsafe {
|
||||
// If the printer is used after the spooler service is started. E.g., printing a document through RustDesk Printer.
|
||||
// `DeletePrinterDriverExW()` may fail with `ERROR_PRINTER_DRIVER_IN_USE`(3001, 0xBB9).
|
||||
// We can only ignore this error for now.
|
||||
// Though restarting the spooler service is a solution, it's not a good idea to restart the service.
|
||||
//
|
||||
// Deleting the printer driver after deleting the printer is a common practice.
|
||||
// No idea why `DeletePrinterDriverExW()` fails with `ERROR_UNKNOWN_PRINTER_DRIVER` after using the printer once.
|
||||
// https://github.com/ChromiumWebApps/chromium/blob/c7361d39be8abd1574e6ce8957c8dbddd4c6ccf7/cloud_print/virtual_driver/win/install/setup.cc#L422
|
||||
// AnyDesk printer driver and the simplest printer driver also have the same issue.
|
||||
if FALSE
|
||||
== DeletePrinterDriverExW(
|
||||
null_mut(),
|
||||
null_mut(),
|
||||
name.as_ptr() as _,
|
||||
DPD_DELETE_ALL_FILES,
|
||||
0,
|
||||
)
|
||||
{
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(ERROR_UNKNOWN_PRINTER_DRIVER as _) {
|
||||
return Ok(());
|
||||
} else {
|
||||
bail!("Failed to delete the printer driver, {}", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// https://github.com/dvalter/chromium-android-ext-dev/blob/dab74f7d5bc5a8adf303090ee25c611b4d54e2db/cloud_print/virtual_driver/win/install/setup.cc#L190
|
||||
fn delete_printer_driver_package(inf: Vec<u16>) -> ResultType<()> {
|
||||
if inf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let slen = if inf[inf.len() - 1] == 0 {
|
||||
inf.len() - 1
|
||||
} else {
|
||||
inf.len()
|
||||
};
|
||||
let inf_path = String::from_utf16_lossy(&inf[..slen]);
|
||||
if !std::path::Path::new(&inf_path).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut retries = 3;
|
||||
loop {
|
||||
unsafe {
|
||||
let res = DeletePrinterDriverPackageW(null_mut(), inf.as_ptr(), null_mut());
|
||||
if res == S_OK || res == HRESULT_ERR_ELEMENT_NOT_FOUND as i32 {
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("Failed to delete the printer driver, result: {}", res);
|
||||
}
|
||||
retries -= 1;
|
||||
if retries <= 0 {
|
||||
bail!("Failed to delete the printer driver");
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uninstall_driver(name: &PCWSTR) -> ResultType<()> {
|
||||
// Note: inf must be found before `delete_printer_driver()`.
|
||||
let inf = find_inf(name)?;
|
||||
delete_printer_driver(name)?;
|
||||
delete_printer_driver_package(inf)
|
||||
}
|
||||
|
||||
pub fn install_driver(name: &PCWSTR, inf: LPCWSTR) -> ResultType<()> {
|
||||
let mut size = (MAX_PATH * 10) as u32;
|
||||
let mut package_path = [0u16; MAX_PATH * 10];
|
||||
unsafe {
|
||||
let mut res = UploadPrinterDriverPackageW(
|
||||
null_mut(),
|
||||
inf,
|
||||
null_mut(),
|
||||
UPDP_SILENT_UPLOAD | UPDP_UPLOAD_ALWAYS,
|
||||
null_mut(),
|
||||
package_path.as_mut_ptr(),
|
||||
&mut size as _,
|
||||
);
|
||||
if res != S_OK {
|
||||
log::error!(
|
||||
"Failed to upload the printer driver package to the driver cache silently, {}. Will try with user UI.",
|
||||
res
|
||||
);
|
||||
|
||||
res = UploadPrinterDriverPackageW(
|
||||
null_mut(),
|
||||
inf,
|
||||
null_mut(),
|
||||
UPDP_UPLOAD_ALWAYS,
|
||||
GetForegroundWindow(),
|
||||
package_path.as_mut_ptr(),
|
||||
&mut size as _,
|
||||
);
|
||||
if res != S_OK {
|
||||
bail!(
|
||||
"Failed to upload the printer driver package to the driver cache with UI, {}",
|
||||
res
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/installprinterdriverfrompackage
|
||||
res = InstallPrinterDriverFromPackageW(
|
||||
null_mut(),
|
||||
package_path.as_ptr(),
|
||||
name.as_ptr(),
|
||||
null_mut(),
|
||||
IPDFP_COPY_ALL_FILES,
|
||||
);
|
||||
if res != S_OK {
|
||||
bail!("Failed to install the printer driver from package, {}", res);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
99
libs/remote_printer/src/setup/mod.rs
Normal file
99
libs/remote_printer/src/setup/mod.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use hbb_common::{bail, ResultType};
|
||||
use std::{io, ptr::null_mut};
|
||||
use winapi::{
|
||||
shared::{
|
||||
minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD},
|
||||
ntdef::{LPCWSTR, LPWSTR},
|
||||
},
|
||||
um::winbase::{lstrcmpiW, lstrlenW},
|
||||
};
|
||||
use windows_strings::PCWSTR;
|
||||
|
||||
mod driver;
|
||||
mod port;
|
||||
pub(crate) mod printer;
|
||||
pub(crate) mod setup;
|
||||
|
||||
#[inline]
|
||||
pub fn is_rd_printer_installed(app_name: &str) -> ResultType<bool> {
|
||||
let printer_name = crate::get_printer_name(app_name);
|
||||
let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr());
|
||||
printer::is_printer_added(&rd_printer_name)
|
||||
}
|
||||
|
||||
fn get_wstr_bytes(p: LPWSTR) -> Vec<u16> {
|
||||
let mut vec_bytes = vec![];
|
||||
unsafe {
|
||||
let len: isize = lstrlenW(p) as _;
|
||||
if len > 0 {
|
||||
for i in 0..len + 1 {
|
||||
vec_bytes.push(*p.offset(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
vec_bytes
|
||||
}
|
||||
|
||||
fn is_name_equal(name: &PCWSTR, name_from_api: LPCWSTR) -> bool {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-lstrcmpiw
|
||||
// For some locales, the lstrcmpi function may be insufficient.
|
||||
// If this occurs, use `CompareStringEx` to ensure proper comparison.
|
||||
// For example, in Japan call with the NORM_IGNORECASE, NORM_IGNOREKANATYPE, and NORM_IGNOREWIDTH values to achieve the most appropriate non-exact string comparison.
|
||||
// Note that specifying these values slows performance, so use them only when necessary.
|
||||
//
|
||||
// No need to consider `CompareStringEx` for now.
|
||||
unsafe { lstrcmpiW(name.as_ptr(), name_from_api) == 0 }
|
||||
}
|
||||
|
||||
fn common_enum<T, R: Sized>(
|
||||
enum_name: &str,
|
||||
enum_fn: fn(
|
||||
Level: DWORD,
|
||||
pDriverInfo: LPBYTE,
|
||||
cbBuf: DWORD,
|
||||
pcbNeeded: LPDWORD,
|
||||
pcReturned: LPDWORD,
|
||||
) -> BOOL,
|
||||
level: DWORD,
|
||||
on_data: impl Fn(&T) -> Option<R>,
|
||||
on_no_data: impl Fn() -> Option<R>,
|
||||
) -> ResultType<Option<R>> {
|
||||
let mut needed = 0;
|
||||
let mut returned = 0;
|
||||
enum_fn(level, null_mut(), 0, &mut needed, &mut returned);
|
||||
if needed == 0 {
|
||||
return Ok(on_no_data());
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u8; needed as usize];
|
||||
if FALSE
|
||||
== enum_fn(
|
||||
level,
|
||||
buffer.as_mut_ptr(),
|
||||
needed,
|
||||
&mut needed,
|
||||
&mut returned,
|
||||
)
|
||||
{
|
||||
bail!(
|
||||
"Failed to call {}, error: {}",
|
||||
enum_name,
|
||||
io::Error::last_os_error()
|
||||
)
|
||||
}
|
||||
|
||||
// to-do: how to free the buffers in *const T?
|
||||
|
||||
let p_enum_info = buffer.as_ptr() as *const T;
|
||||
unsafe {
|
||||
for i in 0..returned {
|
||||
let enum_info = p_enum_info.offset(i as isize);
|
||||
let r = on_data(&*enum_info);
|
||||
if r.is_some() {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(on_no_data())
|
||||
}
|
||||
128
libs/remote_printer/src/setup/port.rs
Normal file
128
libs/remote_printer/src/setup/port.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use super::{common_enum, is_name_equal, printer::get_printer_installed_on_port};
|
||||
use hbb_common::{bail, ResultType};
|
||||
use std::{io, ptr::null_mut};
|
||||
use winapi::{
|
||||
shared::minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD},
|
||||
um::{
|
||||
winnt::HANDLE,
|
||||
winspool::{
|
||||
ClosePrinter, EnumPortsW, OpenPrinterW, XcvDataW, PORT_INFO_2W, PRINTER_DEFAULTSW,
|
||||
SERVER_WRITE,
|
||||
},
|
||||
},
|
||||
};
|
||||
use windows_strings::{w, PCWSTR};
|
||||
|
||||
const XCV_MONITOR_LOCAL_PORT: PCWSTR = w!(",XcvMonitor Local Port");
|
||||
|
||||
fn enum_printer_port(
|
||||
level: DWORD,
|
||||
p_port_info: LPBYTE,
|
||||
cb_buf: DWORD,
|
||||
pcb_needed: LPDWORD,
|
||||
pc_returned: LPDWORD,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumports
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
EnumPortsW(
|
||||
null_mut(),
|
||||
level,
|
||||
p_port_info,
|
||||
cb_buf,
|
||||
pcb_needed,
|
||||
pc_returned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_port_exists(name: &PCWSTR) -> ResultType<bool> {
|
||||
let r = common_enum(
|
||||
"EnumPortsW",
|
||||
enum_printer_port,
|
||||
2,
|
||||
|info: &PORT_INFO_2W| {
|
||||
if is_name_equal(name, info.pPortName) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|| None,
|
||||
)?;
|
||||
Ok(r.unwrap_or(false))
|
||||
}
|
||||
|
||||
unsafe fn execute_on_local_port(port: &PCWSTR, command: &PCWSTR) -> ResultType<()> {
|
||||
let mut dft = PRINTER_DEFAULTSW {
|
||||
pDataType: null_mut(),
|
||||
pDevMode: null_mut(),
|
||||
DesiredAccess: SERVER_WRITE,
|
||||
};
|
||||
let mut h_monitor: HANDLE = null_mut();
|
||||
if FALSE
|
||||
== OpenPrinterW(
|
||||
XCV_MONITOR_LOCAL_PORT.as_ptr() as _,
|
||||
&mut h_monitor,
|
||||
&mut dft as *mut PRINTER_DEFAULTSW as _,
|
||||
)
|
||||
{
|
||||
bail!(format!(
|
||||
"Failed to open Local Port monitor. Error: {}",
|
||||
io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
|
||||
let mut output_needed: u32 = 0;
|
||||
let mut status: u32 = 0;
|
||||
if FALSE
|
||||
== XcvDataW(
|
||||
h_monitor,
|
||||
command.as_ptr(),
|
||||
port.as_ptr() as *mut u8,
|
||||
(port.len() + 1) as u32 * 2,
|
||||
null_mut(),
|
||||
0,
|
||||
&mut output_needed,
|
||||
&mut status,
|
||||
)
|
||||
{
|
||||
ClosePrinter(h_monitor);
|
||||
bail!(format!(
|
||||
"Failed to execute the command on the printer port, Error: {}",
|
||||
io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
|
||||
ClosePrinter(h_monitor);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_local_port(port: &PCWSTR) -> ResultType<()> {
|
||||
unsafe { execute_on_local_port(port, &w!("AddPort")) }
|
||||
}
|
||||
|
||||
fn delete_local_port(port: &PCWSTR) -> ResultType<()> {
|
||||
unsafe { execute_on_local_port(port, &w!("DeletePort")) }
|
||||
}
|
||||
|
||||
pub fn check_add_local_port(port: &PCWSTR) -> ResultType<()> {
|
||||
if !is_port_exists(port)? {
|
||||
return add_local_port(port);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_delete_local_port(port: &PCWSTR) -> ResultType<()> {
|
||||
if is_port_exists(port)? {
|
||||
if get_printer_installed_on_port(port)?.is_some() {
|
||||
bail!("The printer is installed on the port. Please remove the printer first.");
|
||||
}
|
||||
return delete_local_port(port);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
161
libs/remote_printer/src/setup/printer.rs
Normal file
161
libs/remote_printer/src/setup/printer.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use super::{common_enum, get_wstr_bytes, is_name_equal};
|
||||
use hbb_common::{bail, ResultType};
|
||||
use std::{io, ptr::null_mut};
|
||||
use winapi::{
|
||||
shared::{
|
||||
minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD},
|
||||
ntdef::HANDLE,
|
||||
winerror::ERROR_INVALID_PRINTER_NAME,
|
||||
},
|
||||
um::winspool::{
|
||||
AddPrinterW, ClosePrinter, DeletePrinter, EnumPrintersW, OpenPrinterW, SetPrinterW,
|
||||
PRINTER_ALL_ACCESS, PRINTER_ATTRIBUTE_LOCAL, PRINTER_CONTROL_PURGE, PRINTER_DEFAULTSW,
|
||||
PRINTER_ENUM_LOCAL, PRINTER_INFO_1W, PRINTER_INFO_2W,
|
||||
},
|
||||
};
|
||||
use windows_strings::{w, PCWSTR};
|
||||
|
||||
fn enum_local_printer(
|
||||
level: DWORD,
|
||||
p_printer_info: LPBYTE,
|
||||
cb_buf: DWORD,
|
||||
pcb_needed: LPDWORD,
|
||||
pc_returned: LPDWORD,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
EnumPrintersW(
|
||||
PRINTER_ENUM_LOCAL,
|
||||
null_mut(),
|
||||
level,
|
||||
p_printer_info,
|
||||
cb_buf,
|
||||
pcb_needed,
|
||||
pc_returned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_printer_added(name: &PCWSTR) -> ResultType<bool> {
|
||||
let r = common_enum(
|
||||
"EnumPrintersW",
|
||||
enum_local_printer,
|
||||
1,
|
||||
|info: &PRINTER_INFO_1W| {
|
||||
if is_name_equal(name, info.pName) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|| None,
|
||||
)?;
|
||||
Ok(r.unwrap_or(false))
|
||||
}
|
||||
|
||||
// Only return the first matched printer
|
||||
pub fn get_printer_installed_on_port(port: &PCWSTR) -> ResultType<Option<Vec<u16>>> {
|
||||
common_enum(
|
||||
"EnumPrintersW",
|
||||
enum_local_printer,
|
||||
2,
|
||||
|info: &PRINTER_INFO_2W| {
|
||||
if is_name_equal(port, info.pPortName) {
|
||||
Some(get_wstr_bytes(info.pPrinterName))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|| None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn add_printer(name: &PCWSTR, driver: &PCWSTR, port: &PCWSTR) -> ResultType<()> {
|
||||
let mut printer_info = PRINTER_INFO_2W {
|
||||
pServerName: null_mut(),
|
||||
pPrinterName: name.as_ptr() as _,
|
||||
pShareName: null_mut(),
|
||||
pPortName: port.as_ptr() as _,
|
||||
pDriverName: driver.as_ptr() as _,
|
||||
pComment: null_mut(),
|
||||
pLocation: null_mut(),
|
||||
pDevMode: null_mut(),
|
||||
pSepFile: null_mut(),
|
||||
pPrintProcessor: w!("WinPrint").as_ptr() as _,
|
||||
pDatatype: w!("RAW").as_ptr() as _,
|
||||
pParameters: null_mut(),
|
||||
pSecurityDescriptor: null_mut(),
|
||||
Attributes: PRINTER_ATTRIBUTE_LOCAL,
|
||||
Priority: 0,
|
||||
DefaultPriority: 0,
|
||||
StartTime: 0,
|
||||
UntilTime: 0,
|
||||
Status: 0,
|
||||
cJobs: 0,
|
||||
AveragePPM: 0,
|
||||
};
|
||||
unsafe {
|
||||
let h_printer = AddPrinterW(
|
||||
null_mut(),
|
||||
2,
|
||||
&mut printer_info as *mut PRINTER_INFO_2W as _,
|
||||
);
|
||||
if h_printer.is_null() {
|
||||
bail!(format!(
|
||||
"Failed to add printer. Error: {}",
|
||||
io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_printer(name: &PCWSTR) -> ResultType<()> {
|
||||
let mut dft = PRINTER_DEFAULTSW {
|
||||
pDataType: null_mut(),
|
||||
pDevMode: null_mut(),
|
||||
DesiredAccess: PRINTER_ALL_ACCESS,
|
||||
};
|
||||
let mut h_printer: HANDLE = null_mut();
|
||||
unsafe {
|
||||
if FALSE
|
||||
== OpenPrinterW(
|
||||
name.as_ptr() as _,
|
||||
&mut h_printer,
|
||||
&mut dft as *mut PRINTER_DEFAULTSW as _,
|
||||
)
|
||||
{
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(ERROR_INVALID_PRINTER_NAME as _) {
|
||||
return Ok(());
|
||||
} else {
|
||||
bail!(format!("Failed to open printer. Error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
if FALSE == SetPrinterW(h_printer, 0, null_mut(), PRINTER_CONTROL_PURGE) {
|
||||
ClosePrinter(h_printer);
|
||||
bail!(format!(
|
||||
"Failed to purge printer queue. Error: {}",
|
||||
io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
|
||||
if FALSE == DeletePrinter(h_printer) {
|
||||
ClosePrinter(h_printer);
|
||||
bail!(format!(
|
||||
"Failed to delete printer. Error: {}",
|
||||
io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
|
||||
ClosePrinter(h_printer);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
94
libs/remote_printer/src/setup/setup.rs
Normal file
94
libs/remote_printer/src/setup/setup.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use super::{
|
||||
driver::{get_installed_driver_version, install_driver, uninstall_driver},
|
||||
port::{check_add_local_port, check_delete_local_port},
|
||||
printer::{add_printer, delete_printer},
|
||||
};
|
||||
use hbb_common::{allow_err, bail, lazy_static, log, ResultType};
|
||||
use std::{path::PathBuf, sync::Mutex};
|
||||
use windows_strings::PCWSTR;
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
static ref SETUP_MTX: Mutex<()> = Mutex::new(());
|
||||
);
|
||||
|
||||
fn get_driver_inf_abs_path() -> ResultType<PathBuf> {
|
||||
use crate::RD_DRIVER_INF_PATH;
|
||||
|
||||
let exe_file = std::env::current_exe()?;
|
||||
let abs_path = match exe_file.parent() {
|
||||
Some(parent) => parent.join(RD_DRIVER_INF_PATH),
|
||||
None => bail!(
|
||||
"Invalid exe parent for {}",
|
||||
exe_file.to_string_lossy().as_ref()
|
||||
),
|
||||
};
|
||||
if !abs_path.exists() {
|
||||
bail!(
|
||||
"The driver inf file \"{}\" does not exists",
|
||||
RD_DRIVER_INF_PATH
|
||||
)
|
||||
}
|
||||
Ok(abs_path)
|
||||
}
|
||||
|
||||
// Note: This function must be called in a separate thread.
|
||||
// Because many functions in this module are blocking or synchronous.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
// Steps:
|
||||
// 1. Add the local port.
|
||||
// 2. Check if the driver is installed.
|
||||
// Uninstall the existing driver if it is installed.
|
||||
// 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.
|
||||
pub fn install_update_printer(app_name: &str) -> ResultType<()> {
|
||||
let printer_name = crate::get_printer_name(app_name);
|
||||
let driver_name = crate::get_driver_name();
|
||||
let port = crate::get_port_name(app_name);
|
||||
let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr());
|
||||
let rd_printer_driver_name = PCWSTR::from_raw(driver_name.as_ptr());
|
||||
let rd_printer_port = PCWSTR::from_raw(port.as_ptr());
|
||||
|
||||
let inf_file = get_driver_inf_abs_path()?;
|
||||
let inf_file: Vec<u16> = inf_file
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
.encode_utf16()
|
||||
.chain(Some(0).into_iter())
|
||||
.collect();
|
||||
let _lock = SETUP_MTX.lock().unwrap();
|
||||
|
||||
check_add_local_port(&rd_printer_port)?;
|
||||
|
||||
let should_install_driver = match get_installed_driver_version(&rd_printer_driver_name)? {
|
||||
Some(_version) => {
|
||||
delete_printer(&rd_printer_name)?;
|
||||
allow_err!(uninstall_driver(&rd_printer_driver_name));
|
||||
true
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_install_driver {
|
||||
allow_err!(install_driver(&rd_printer_driver_name, inf_file.as_ptr()));
|
||||
}
|
||||
|
||||
add_printer(&rd_printer_name, &rd_printer_driver_name, &rd_printer_port)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn uninstall_printer(app_name: &str) {
|
||||
let printer_name = crate::get_printer_name(app_name);
|
||||
let driver_name = crate::get_driver_name();
|
||||
let port = crate::get_port_name(app_name);
|
||||
let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr());
|
||||
let rd_printer_driver_name = PCWSTR::from_raw(driver_name.as_ptr());
|
||||
let rd_printer_port = PCWSTR::from_raw(port.as_ptr());
|
||||
|
||||
let _lock = SETUP_MTX.lock().unwrap();
|
||||
|
||||
allow_err!(delete_printer(&rd_printer_name));
|
||||
allow_err!(uninstall_driver(&rd_printer_driver_name));
|
||||
allow_err!(check_delete_local_port(&rd_printer_port));
|
||||
}
|
||||
@@ -62,3 +62,6 @@ gstreamer-video = { version = "0.16", optional = true }
|
||||
git = "https://github.com/rustdesk-org/hwcodec"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
nokhwa = { git = "https://github.com/rustdesk-org/nokhwa.git", branch = "fix_from_raw_parts", features = ["input-native"] }
|
||||
|
||||
|
||||
267
libs/scrap/src/common/camera.rs
Normal file
267
libs/scrap/src/common/camera.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use nokhwa::{
|
||||
pixel_format::RgbAFormat,
|
||||
query,
|
||||
utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType},
|
||||
Camera,
|
||||
};
|
||||
|
||||
use hbb_common::message_proto::{DisplayInfo, Resolution};
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
use crate::AdapterDevice;
|
||||
|
||||
use crate::common::{bail, ResultType};
|
||||
use crate::{Frame, PixelBuffer, Pixfmt, TraitCapturer};
|
||||
|
||||
pub const PRIMARY_CAMERA_IDX: usize = 0;
|
||||
lazy_static::lazy_static! {
|
||||
static ref SYNC_CAMERA_DISPLAYS: Arc<Mutex<Vec<DisplayInfo>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
const CAMERA_NOT_SUPPORTED: &str = "This platform doesn't support camera yet";
|
||||
|
||||
pub struct Cameras;
|
||||
|
||||
// pre-condition
|
||||
pub fn primary_camera_exists() -> bool {
|
||||
Cameras::exists(PRIMARY_CAMERA_IDX)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
impl Cameras {
|
||||
pub fn all_info() -> ResultType<Vec<DisplayInfo>> {
|
||||
match query(ApiBackend::Auto) {
|
||||
Ok(cameras) => {
|
||||
let mut camera_displays = SYNC_CAMERA_DISPLAYS.lock().unwrap();
|
||||
camera_displays.clear();
|
||||
// FIXME: nokhwa returns duplicate info for one physical camera on linux for now.
|
||||
// issue: https://github.com/l1npengtul/nokhwa/issues/171
|
||||
// Use only one camera as a temporary hack.
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "linux")] {
|
||||
let Some(info) = cameras.first() else {
|
||||
bail!("No camera found")
|
||||
};
|
||||
let camera = Self::create_camera(info.index())?;
|
||||
let resolution = camera.resolution();
|
||||
let (width, height) = (resolution.width() as i32, resolution.height() as i32);
|
||||
camera_displays.push(DisplayInfo {
|
||||
x: 0,
|
||||
y: 0,
|
||||
name: info.human_name().clone(),
|
||||
width,
|
||||
height,
|
||||
online: true,
|
||||
cursor_embedded: false,
|
||||
scale:1.0,
|
||||
original_resolution: Some(Resolution {
|
||||
width,
|
||||
height,
|
||||
..Default::default()
|
||||
}).into(),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
let mut x = 0;
|
||||
for info in &cameras {
|
||||
let camera = Self::create_camera(info.index())?;
|
||||
let resolution = camera.resolution();
|
||||
let (width, height) = (resolution.width() as i32, resolution.height() as i32);
|
||||
camera_displays.push(DisplayInfo {
|
||||
x,
|
||||
y: 0,
|
||||
name: info.human_name().clone(),
|
||||
width,
|
||||
height,
|
||||
online: true,
|
||||
cursor_embedded: false,
|
||||
scale:1.0,
|
||||
original_resolution: Some(Resolution {
|
||||
width,
|
||||
height,
|
||||
..Default::default()
|
||||
}).into(),
|
||||
..Default::default()
|
||||
});
|
||||
x += width;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(camera_displays.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("Query cameras error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exists(index: usize) -> bool {
|
||||
match query(ApiBackend::Auto) {
|
||||
Ok(cameras) => index < cameras.len(),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_camera(index: &CameraIndex) -> ResultType<Camera> {
|
||||
let result = Camera::new(
|
||||
index.clone(),
|
||||
RequestedFormat::new::<RgbAFormat>(RequestedFormatType::AbsoluteHighestResolution),
|
||||
);
|
||||
match result {
|
||||
Ok(camera) => Ok(camera),
|
||||
Err(e) => bail!("create camera{} error: {}", index, e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_camera_resolution(index: usize) -> ResultType<Resolution> {
|
||||
let index = CameraIndex::Index(index as u32);
|
||||
let camera = Self::create_camera(&index)?;
|
||||
let resolution = camera.resolution();
|
||||
Ok(Resolution {
|
||||
width: resolution.width() as i32,
|
||||
height: resolution.height() as i32,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sync_cameras() -> Vec<DisplayInfo> {
|
||||
SYNC_CAMERA_DISPLAYS.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_capturer(current: usize) -> ResultType<Box<dyn TraitCapturer>> {
|
||||
Ok(Box::new(CameraCapturer::new(current)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
impl Cameras {
|
||||
pub fn all_info() -> ResultType<Vec<DisplayInfo>> {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
pub fn exists(index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_camera_resolution(index: usize) -> ResultType<Resolution> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
pub fn get_sync_cameras() -> Vec<DisplayInfo> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn get_capturer(current: usize) -> ResultType<Box<dyn TraitCapturer>> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
pub struct CameraCapturer {
|
||||
camera: Camera,
|
||||
data: Vec<u8>,
|
||||
last_data: Vec<u8>, // for faster compare and copy
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub struct CameraCapturer;
|
||||
|
||||
impl CameraCapturer {
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn new(current: usize) -> ResultType<Self> {
|
||||
let index = CameraIndex::Index(current as u32);
|
||||
let camera = Cameras::create_camera(&index)?;
|
||||
Ok(CameraCapturer {
|
||||
camera,
|
||||
data: Vec::new(),
|
||||
last_data: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
fn new(_current: usize) -> ResultType<Self> {
|
||||
bail!(CAMERA_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
|
||||
impl TraitCapturer for CameraCapturer {
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn frame<'a>(&'a mut self, _timeout: std::time::Duration) -> std::io::Result<Frame<'a>> {
|
||||
// TODO: move this check outside `frame`.
|
||||
if !self.camera.is_stream_open() {
|
||||
if let Err(e) = self.camera.open_stream() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Camera open stream error: {}", e),
|
||||
));
|
||||
}
|
||||
}
|
||||
match self.camera.frame() {
|
||||
Ok(buffer) => {
|
||||
match buffer.decode_image::<RgbAFormat>() {
|
||||
Ok(decoded) => {
|
||||
self.data = decoded.as_raw().to_vec();
|
||||
crate::would_block_if_equal(&mut self.last_data, &self.data)?;
|
||||
// FIXME: macos's PixelBuffer cannot be directly created from bytes slice.
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "linux", target_os = "windows"))] {
|
||||
Ok(Frame::PixelBuffer(PixelBuffer::new(
|
||||
&self.data,
|
||||
Pixfmt::RGBA,
|
||||
decoded.width() as usize,
|
||||
decoded.height() as usize,
|
||||
)))
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Camera is not supported on this platform yet"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Camera frame decode error: {}", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Camera frame error: {}", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
fn frame<'a>(&'a mut self, _timeout: std::time::Duration) -> std::io::Result<Frame<'a>> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
CAMERA_NOT_SUPPORTED.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_gdi(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_gdi(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
fn device(&self) -> AdapterDevice {
|
||||
AdapterDevice::default()
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
fn set_output_texture(&mut self, _texture: bool) {}
|
||||
}
|
||||
@@ -864,7 +864,7 @@ pub fn enable_vram_option(encode: bool) -> bool {
|
||||
if encode {
|
||||
enable && enable_directx_capture()
|
||||
} else {
|
||||
enable
|
||||
enable && allow_d3d_render()
|
||||
}
|
||||
} else {
|
||||
false
|
||||
@@ -874,10 +874,13 @@ pub fn enable_vram_option(encode: bool) -> bool {
|
||||
#[cfg(windows)]
|
||||
pub fn enable_directx_capture() -> bool {
|
||||
use hbb_common::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE as OPTION;
|
||||
option2bool(
|
||||
OPTION,
|
||||
&Config::get_option(hbb_common::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE),
|
||||
)
|
||||
option2bool(OPTION, &Config::get_option(OPTION))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn allow_d3d_render() -> bool {
|
||||
use hbb_common::config::keys::OPTION_ALLOW_D3D_RENDER as OPTION;
|
||||
option2bool(OPTION, &hbb_common::config::LocalConfig::get_option(OPTION))
|
||||
}
|
||||
|
||||
pub const BR_BEST: f32 = 1.5;
|
||||
|
||||
@@ -70,23 +70,30 @@ impl TraitCapturer for Capturer {
|
||||
|
||||
pub struct PixelBuffer<'a> {
|
||||
data: &'a [u8],
|
||||
pixfmt: Pixfmt,
|
||||
width: usize,
|
||||
height: usize,
|
||||
stride: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<'a> PixelBuffer<'a> {
|
||||
pub fn new(data: &'a [u8], width: usize, height: usize) -> Self {
|
||||
pub fn new(data: &'a [u8], pixfmt: Pixfmt, width: usize, height: usize) -> Self {
|
||||
let stride0 = data.len() / height;
|
||||
let mut stride = Vec::new();
|
||||
stride.push(stride0);
|
||||
PixelBuffer {
|
||||
data,
|
||||
pixfmt,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn with_BGRA(data: &'a [u8], width: usize, height: usize) -> Self {
|
||||
Self::new(data, Pixfmt::BGRA, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> {
|
||||
@@ -107,7 +114,7 @@ impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> {
|
||||
}
|
||||
|
||||
fn pixfmt(&self) -> Pixfmt {
|
||||
Pixfmt::BGRA
|
||||
self.pixfmt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +239,7 @@ impl CapturerMag {
|
||||
impl TraitCapturer for CapturerMag {
|
||||
fn frame<'a>(&'a mut self, _timeout_ms: Duration) -> io::Result<Frame<'a>> {
|
||||
self.inner.frame(&mut self.data)?;
|
||||
Ok(Frame::PixelBuffer(PixelBuffer::new(
|
||||
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
|
||||
&self.data,
|
||||
self.inner.get_rect().1,
|
||||
self.inner.get_rect().2,
|
||||
|
||||
@@ -49,6 +49,8 @@ pub const STRIDE_ALIGN: usize = 64; // commonly used in libvpx vpx_img_alloc cal
|
||||
pub const HW_STRIDE_ALIGN: usize = 0; // recommended by av_frame_get_buffer
|
||||
|
||||
pub mod aom;
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
pub mod camera;
|
||||
pub mod record;
|
||||
mod vpx;
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ pub struct RecorderContext {
|
||||
pub server: bool,
|
||||
pub id: String,
|
||||
pub dir: String,
|
||||
pub display: usize,
|
||||
pub display_idx: usize,
|
||||
pub camera: bool,
|
||||
pub tx: Option<Sender<RecordState>>,
|
||||
}
|
||||
|
||||
@@ -46,7 +47,11 @@ impl RecorderContext2 {
|
||||
+ "_"
|
||||
+ &ctx.id.clone()
|
||||
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
|
||||
+ &format!("display{}_", ctx.display)
|
||||
+ &format!(
|
||||
"{}{}_",
|
||||
if ctx.camera { "camera" } else { "display" },
|
||||
ctx.display_idx
|
||||
)
|
||||
+ &self.format.to_string().to_lowercase()
|
||||
+ if self.format == CodecFormat::VP9
|
||||
|| self.format == CodecFormat::VP8
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
codec::{base_bitrate, enable_vram_option, EncoderApi, EncoderCfg},
|
||||
codec::{enable_vram_option, EncoderApi, EncoderCfg},
|
||||
hwcodec::HwCodecConfig,
|
||||
AdapterDevice, CodecFormat, EncodeInput, EncodeYuvFormat, Pixfmt,
|
||||
};
|
||||
@@ -30,8 +30,8 @@ use hwcodec::{
|
||||
// https://cybersided.com/two-monitors-two-gpus/
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12device-getadapterluid#remarks
|
||||
lazy_static::lazy_static! {
|
||||
static ref ENOCDE_NOT_USE: Arc<Mutex<HashMap<usize, bool>>> = Default::default();
|
||||
static ref FALLBACK_GDI_DISPLAYS: Arc<Mutex<HashSet<usize>>> = Default::default();
|
||||
static ref ENOCDE_NOT_USE: Arc<Mutex<HashMap<String, bool>>> = Default::default();
|
||||
static ref FALLBACK_GDI_DISPLAYS: Arc<Mutex<HashSet<String>>> = Default::default();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -287,16 +287,25 @@ impl VRamEncoder {
|
||||
crate::hwcodec::HwRamEncoder::calc_bitrate(width, height, ratio, fmt == DataFormat::H264)
|
||||
}
|
||||
|
||||
pub fn set_not_use(display: usize, not_use: bool) {
|
||||
log::info!("set display#{display} not use vram encode to {not_use}");
|
||||
ENOCDE_NOT_USE.lock().unwrap().insert(display, not_use);
|
||||
pub fn set_not_use(video_service_name: String, not_use: bool) {
|
||||
log::info!("set {video_service_name} not use vram encode to {not_use}");
|
||||
ENOCDE_NOT_USE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(video_service_name, not_use);
|
||||
}
|
||||
|
||||
pub fn set_fallback_gdi(display: usize, fallback: bool) {
|
||||
pub fn set_fallback_gdi(video_service_name: String, fallback: bool) {
|
||||
if fallback {
|
||||
FALLBACK_GDI_DISPLAYS.lock().unwrap().insert(display);
|
||||
FALLBACK_GDI_DISPLAYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(video_service_name);
|
||||
} else {
|
||||
FALLBACK_GDI_DISPLAYS.lock().unwrap().remove(&display);
|
||||
FALLBACK_GDI_DISPLAYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&video_service_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use winapi::{
|
||||
shared::{
|
||||
dxgi::*,
|
||||
dxgi1_2::*,
|
||||
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
dxgitype::*,
|
||||
minwindef::{DWORD, FALSE, TRUE, UINT},
|
||||
ntdef::LONG,
|
||||
@@ -118,6 +117,7 @@ impl Capturer {
|
||||
} else {
|
||||
hres
|
||||
}
|
||||
|
||||
// NVFBC(NVIDIA Capture SDK) which xpra used already deprecated, https://developer.nvidia.com/capture-sdk
|
||||
|
||||
// also try high version DXGI for better performance, e.g.
|
||||
@@ -129,6 +129,8 @@ impl Capturer {
|
||||
// can help us update screen incrementally
|
||||
|
||||
/* // not supported on my PC, try in the future
|
||||
use winapi::shared::dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM;
|
||||
|
||||
let format : Vec<DXGI_FORMAT> = vec![DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE];
|
||||
(*display.inner).DuplicateOutput1(
|
||||
device as *mut _,
|
||||
@@ -394,7 +396,7 @@ impl Capturer {
|
||||
} else {
|
||||
let width = self.width;
|
||||
let height = self.height;
|
||||
Ok(Frame::PixelBuffer(PixelBuffer::new(
|
||||
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
|
||||
self.get_pixelbuffer(timeout)?,
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.3.8
|
||||
pkgver=1.3.9
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
@@ -23,7 +23,8 @@ remote = open('src/ui/remote.html').read() \
|
||||
.replace('include "grid.tis";', open('src/ui/grid.tis').read()) \
|
||||
.replace('include "header.tis";', open('src/ui/header.tis').read()) \
|
||||
.replace('include "file_transfer.tis";', open('src/ui/file_transfer.tis').read()) \
|
||||
.replace('include "port_forward.tis";', open('src/ui/port_forward.tis').read())
|
||||
.replace('include "port_forward.tis";', open('src/ui/port_forward.tis').read()) \
|
||||
.replace('include "printer.tis";', open('src/ui/printer.tis').read())
|
||||
|
||||
chatbox = open('src/ui/chatbox.html').read()
|
||||
install = open('src/ui/install.html').read().replace('include "install.tis";', open('src/ui/install.tis').read())
|
||||
|
||||
@@ -15,3 +15,9 @@ bool MyStopServiceW(LPCWSTR serviceName);
|
||||
std::wstring ReadConfig(const std::wstring& filename, const std::wstring& key);
|
||||
|
||||
void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
|
||||
|
||||
namespace RemotePrinter
|
||||
{
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder);
|
||||
VOID uninstallPrinter();
|
||||
}
|
||||
|
||||
@@ -878,3 +878,55 @@ void TryStopDeleteServiceByShell(LPWSTR svcName)
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to delete service: \"%ls\" with shell, current status: %d.", svcName, svcStatus.dwCurrentState);
|
||||
}
|
||||
}
|
||||
|
||||
UINT __stdcall InstallPrinter(
|
||||
__in MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
int nResult = 0;
|
||||
LPWSTR installFolder = NULL;
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
|
||||
hr = WcaInitialize(hInstall, "InstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
|
||||
RemotePrinter::installUpdatePrinter(installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
||||
|
||||
LExit:
|
||||
if (pwzData) {
|
||||
ReleaseStr(pwzData);
|
||||
}
|
||||
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
UINT __stdcall UninstallPrinter(
|
||||
__in MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
|
||||
RemotePrinter::uninstallPrinter();
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
||||
|
||||
LExit:
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,5 @@ EXPORTS
|
||||
SetPropertyFromConfig
|
||||
AddRegSoftwareSASGeneration
|
||||
RemoveAmyuniIdd
|
||||
InstallPrinter
|
||||
UninstallPrinter
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<ClCompile Include="ReadConfig.cpp" />
|
||||
<ClCompile Include="RemotePrinter.cpp" />
|
||||
<ClCompile Include="ServiceUtils.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
517
res/msi/CustomActions/RemotePrinter.cpp
Normal file
517
res/msi/CustomActions/RemotePrinter.cpp
Normal file
@@ -0,0 +1,517 @@
|
||||
#include "pch.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <winspool.h>
|
||||
#include <setupapi.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <iostream>
|
||||
|
||||
#include "Common.h"
|
||||
|
||||
#pragma comment(lib, "setupapi.lib")
|
||||
#pragma comment(lib, "winspool.lib")
|
||||
|
||||
namespace RemotePrinter
|
||||
{
|
||||
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
|
||||
|
||||
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 XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
|
||||
|
||||
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 &)>;
|
||||
template <typename R>
|
||||
using FuncOnNoData = std::function<std::shared_ptr<R>()>;
|
||||
|
||||
template <class T, class R>
|
||||
std::shared_ptr<R> commonEnum(std::wstring funcName, FuncEnum func, DWORD level, FuncOnData<T, R> onData, FuncOnNoData<R> onNoData)
|
||||
{
|
||||
DWORD needed = 0;
|
||||
DWORD returned = 0;
|
||||
func(level, NULL, 0, &needed, &returned);
|
||||
if (needed == 0)
|
||||
{
|
||||
return onNoData();
|
||||
}
|
||||
|
||||
std::vector<BYTE> buffer(needed);
|
||||
if (!func(level, buffer.data(), needed, &needed, &returned))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
T *pPortInfo = reinterpret_cast<T *>(buffer.data());
|
||||
for (DWORD i = 0; i < returned; i++)
|
||||
{
|
||||
auto r = onData(pPortInfo[i]);
|
||||
if (r)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return onNoData();
|
||||
}
|
||||
|
||||
BOOL isNameEqual(LPCWSTR lhs, LPCWSTR rhs)
|
||||
{
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-lstrcmpiw
|
||||
// For some locales, the lstrcmpi function may be insufficient.
|
||||
// If this occurs, use `CompareStringEx` to ensure proper comparison.
|
||||
// For example, in Japan call with the NORM_IGNORECASE, NORM_IGNOREKANATYPE, and NORM_IGNOREWIDTH values to achieve the most appropriate non-exact string comparison.
|
||||
// Note that specifying these values slows performance, so use them only when necessary.
|
||||
//
|
||||
// No need to consider `CompareStringEx` for now.
|
||||
return lstrcmpiW(lhs, rhs) == 0 ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
BOOL enumPrinterPort(
|
||||
DWORD level,
|
||||
LPBYTE pPortInfo,
|
||||
DWORD cbBuf,
|
||||
LPDWORD pcbNeeded,
|
||||
LPDWORD pcReturned)
|
||||
{
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumports
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
return EnumPortsW(NULL, level, pPortInfo, cbBuf, pcbNeeded, pcReturned);
|
||||
}
|
||||
|
||||
BOOL isPortExists(LPCWSTR port)
|
||||
{
|
||||
auto onData = [port](const PORT_INFO_2 &info)
|
||||
{
|
||||
if (isNameEqual(info.pPortName, port) == TRUE) {
|
||||
return std::shared_ptr<BOOL>(new BOOL(TRUE));
|
||||
}
|
||||
else {
|
||||
return std::shared_ptr<BOOL>(nullptr);
|
||||
} };
|
||||
auto onNoData = []()
|
||||
{ return nullptr; };
|
||||
auto res = commonEnum<PORT_INFO_2, BOOL>(L"EnumPortsW", enumPrinterPort, 2, onData, onNoData);
|
||||
if (res == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return *res;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL executeOnLocalPort(LPCWSTR port, LPCWSTR command)
|
||||
{
|
||||
PRINTER_DEFAULTSW dft = {0};
|
||||
dft.DesiredAccess = SERVER_WRITE;
|
||||
HANDLE hMonitor = NULL;
|
||||
if (OpenPrinterW(const_cast<LPWSTR>(XCV_MONITOR_LOCAL_PORT), &hMonitor, &dft) == FALSE)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
DWORD outputNeeded = 0;
|
||||
DWORD status = 0;
|
||||
if (XcvDataW(hMonitor, command, (LPBYTE)port, (lstrlenW(port) + 1) * 2, NULL, 0, &outputNeeded, &status) == FALSE)
|
||||
{
|
||||
ClosePrinter(hMonitor);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
ClosePrinter(hMonitor);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL addLocalPort(LPCWSTR port)
|
||||
{
|
||||
return executeOnLocalPort(port, L"AddPort");
|
||||
}
|
||||
|
||||
BOOL deleteLocalPort(LPCWSTR port)
|
||||
{
|
||||
return executeOnLocalPort(port, L"DeletePort");
|
||||
}
|
||||
|
||||
BOOL checkAddLocalPort(LPCWSTR port)
|
||||
{
|
||||
if (!isPortExists(port))
|
||||
{
|
||||
return addLocalPort(port);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
std::wstring getPrinterInstalledOnPort(LPCWSTR port);
|
||||
|
||||
BOOL checkDeleteLocalPort(LPCWSTR port)
|
||||
{
|
||||
if (isPortExists(port))
|
||||
{
|
||||
if (getPrinterInstalledOnPort(port) != L"")
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "The printer is installed on the port. Please remove the printer first.\n");
|
||||
return FALSE;
|
||||
}
|
||||
return deleteLocalPort(port);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL enumPrinterDriver(
|
||||
DWORD level,
|
||||
LPBYTE pDriverInfo,
|
||||
DWORD cbBuf,
|
||||
LPDWORD pcbNeeded,
|
||||
LPDWORD pcReturned)
|
||||
{
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinterdrivers
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
return EnumPrinterDriversW(
|
||||
NULL,
|
||||
NULL,
|
||||
level,
|
||||
pDriverInfo,
|
||||
cbBuf,
|
||||
pcbNeeded,
|
||||
pcReturned);
|
||||
}
|
||||
|
||||
DWORDLONG getInstalledDriverVersion(LPCWSTR name)
|
||||
{
|
||||
auto onData = [name](const DRIVER_INFO_6W &info)
|
||||
{
|
||||
if (isNameEqual(name, info.pName) == TRUE)
|
||||
{
|
||||
return std::shared_ptr<DWORDLONG>(new DWORDLONG(info.dwlDriverVersion));
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::shared_ptr<DWORDLONG>(nullptr);
|
||||
} };
|
||||
auto onNoData = []()
|
||||
{ return nullptr; };
|
||||
auto res = commonEnum<DRIVER_INFO_6W, DWORDLONG>(L"EnumPrinterDriversW", enumPrinterDriver, 6, onData, onNoData);
|
||||
if (res == nullptr)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
return *res;
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring findInf(LPCWSTR name)
|
||||
{
|
||||
auto onData = [name](const DRIVER_INFO_8W &info)
|
||||
{
|
||||
if (isNameEqual(name, info.pName) == TRUE)
|
||||
{
|
||||
return std::shared_ptr<std::wstring>(new std::wstring(info.pszInfPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::shared_ptr<std::wstring>(nullptr);
|
||||
} };
|
||||
auto onNoData = []()
|
||||
{ return nullptr; };
|
||||
auto res = commonEnum<DRIVER_INFO_8W, std::wstring>(L"EnumPrinterDriversW", enumPrinterDriver, 8, onData, onNoData);
|
||||
if (res == nullptr)
|
||||
{
|
||||
return L"";
|
||||
}
|
||||
else
|
||||
{
|
||||
return *res;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL deletePrinterDriver(LPCWSTR name)
|
||||
{
|
||||
// If the printer is used after the spooler service is started. E.g., printing a document through RustDesk Printer.
|
||||
// `DeletePrinterDriverExW()` may fail with `ERROR_PRINTER_DRIVER_IN_USE`(3001, 0xBB9).
|
||||
// We can only ignore this error for now.
|
||||
// Though restarting the spooler service is a solution, it's not a good idea to restart the service.
|
||||
//
|
||||
// Deleting the printer driver after deleting the printer is a common practice.
|
||||
// No idea why `DeletePrinterDriverExW()` fails with `ERROR_UNKNOWN_PRINTER_DRIVER` after using the printer once.
|
||||
// https://github.com/ChromiumWebApps/chromium/blob/c7361d39be8abd1574e6ce8957c8dbddd4c6ccf7/cloud_print/virtual_driver/win/install/setup.cc#L422
|
||||
// AnyDesk printer driver and the simplest printer driver also have the same issue.
|
||||
BOOL res = DeletePrinterDriverExW(NULL, NULL, const_cast<LPWSTR>(name), DPD_DELETE_ALL_FILES, 0);
|
||||
if (res == FALSE)
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_UNKNOWN_PRINTER_DRIVER)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
else
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to delete printer driver. Error (%d)\n", error);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
BOOL deletePrinterDriverPackage(const std::wstring &inf)
|
||||
{
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/deleteprinterdriverpackage
|
||||
// This function is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
int tries = 3;
|
||||
HRESULT result = S_FALSE;
|
||||
while ((result = DeletePrinterDriverPackage(NULL, inf.c_str(), NULL)) != S_OK)
|
||||
{
|
||||
if (result == HRESULT_ERR_ELEMENT_NOT_FOUND)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to delete printer driver package. HRESULT (%d)\n", result);
|
||||
tries--;
|
||||
if (tries <= 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
Sleep(2000);
|
||||
}
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
BOOL uninstallDriver(LPCWSTR name)
|
||||
{
|
||||
auto infFile = findInf(name);
|
||||
if (!deletePrinterDriver(name))
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
if (infFile != L"" && !deletePrinterDriverPackage(infFile))
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL installDriver(LPCWSTR name, LPCWSTR inf)
|
||||
{
|
||||
DWORD size = MAX_PATH * 10;
|
||||
wchar_t package_path[MAX_PATH * 10] = {0};
|
||||
HRESULT result = UploadPrinterDriverPackage(
|
||||
NULL, inf, NULL,
|
||||
UPDP_SILENT_UPLOAD | UPDP_UPLOAD_ALWAYS, NULL, package_path, &size);
|
||||
if (result != S_OK)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Uploading the printer driver package to the driver cache silently, failed. Will retry with user UI. HRESULT (%d)\n", result);
|
||||
result = UploadPrinterDriverPackage(
|
||||
NULL, inf, NULL, UPDP_UPLOAD_ALWAYS,
|
||||
GetForegroundWindow(), package_path, &size);
|
||||
if (result != S_OK)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Uploading the printer driver package to the driver cache failed with user UI. Aborting...\n");
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
result = InstallPrinterDriverFromPackage(
|
||||
NULL, package_path, name, NULL, IPDFP_COPY_ALL_FILES);
|
||||
if (result != S_OK)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Installing the printer driver failed. HRESULT (%d)\n", result);
|
||||
}
|
||||
return result == S_OK;
|
||||
}
|
||||
|
||||
BOOL enumLocalPrinter(
|
||||
DWORD level,
|
||||
LPBYTE pPrinterInfo,
|
||||
DWORD cbBuf,
|
||||
LPDWORD pcbNeeded,
|
||||
LPDWORD pcReturned)
|
||||
{
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters
|
||||
// This is a blocking or synchronous function and might not return immediately.
|
||||
// How quickly this function returns depends on run-time factors
|
||||
// such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application.
|
||||
// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive.
|
||||
return EnumPrintersW(PRINTER_ENUM_LOCAL, NULL, level, pPrinterInfo, cbBuf, pcbNeeded, pcReturned);
|
||||
}
|
||||
|
||||
BOOL isPrinterAdded(LPCWSTR name)
|
||||
{
|
||||
auto onData = [name](const PRINTER_INFO_1W &info)
|
||||
{
|
||||
if (isNameEqual(name, info.pName) == TRUE)
|
||||
{
|
||||
return std::shared_ptr<BOOL>(new BOOL(TRUE));
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::shared_ptr<BOOL>(nullptr);
|
||||
} };
|
||||
auto onNoData = []()
|
||||
{ return nullptr; };
|
||||
auto res = commonEnum<PRINTER_INFO_1W, BOOL>(L"EnumPrintersW", enumLocalPrinter, 1, onData, onNoData);
|
||||
if (res == nullptr)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
else
|
||||
{
|
||||
return *res;
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring getPrinterInstalledOnPort(LPCWSTR port)
|
||||
{
|
||||
auto onData = [port](const PRINTER_INFO_2W &info)
|
||||
{
|
||||
if (isNameEqual(port, info.pPortName) == TRUE)
|
||||
{
|
||||
return std::shared_ptr<std::wstring>(new std::wstring(info.pPrinterName));
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::shared_ptr<std::wstring>(nullptr);
|
||||
} };
|
||||
auto onNoData = []()
|
||||
{ return nullptr; };
|
||||
auto res = commonEnum<PRINTER_INFO_2W, std::wstring>(L"EnumPrintersW", enumLocalPrinter, 2, onData, onNoData);
|
||||
if (res == nullptr)
|
||||
{
|
||||
return L"";
|
||||
}
|
||||
else
|
||||
{
|
||||
return *res;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL addPrinter(LPCWSTR name, LPCWSTR driver, LPCWSTR port)
|
||||
{
|
||||
PRINTER_INFO_2W printerInfo = {0};
|
||||
printerInfo.pPrinterName = const_cast<LPWSTR>(name);
|
||||
printerInfo.pPortName = const_cast<LPWSTR>(port);
|
||||
printerInfo.pDriverName = const_cast<LPWSTR>(driver);
|
||||
printerInfo.pPrintProcessor = const_cast<LPWSTR>(L"WinPrint");
|
||||
printerInfo.pDatatype = const_cast<LPWSTR>(L"RAW");
|
||||
printerInfo.Attributes = PRINTER_ATTRIBUTE_LOCAL;
|
||||
HANDLE hPrinter = AddPrinterW(NULL, 2, (LPBYTE)&printerInfo);
|
||||
return hPrinter == NULL ? FALSE : TRUE;
|
||||
}
|
||||
|
||||
VOID deletePrinter(LPCWSTR name)
|
||||
{
|
||||
PRINTER_DEFAULTSW dft = {0};
|
||||
dft.DesiredAccess = PRINTER_ALL_ACCESS;
|
||||
HANDLE hPrinter = NULL;
|
||||
if (OpenPrinterW(const_cast<LPWSTR>(name), &hPrinter, &dft) == FALSE)
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_INVALID_PRINTER_NAME)
|
||||
{
|
||||
return;
|
||||
}
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to open printer. error (%d)\n", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SetPrinterW(hPrinter, 0, NULL, PRINTER_CONTROL_PURGE) == FALSE)
|
||||
{
|
||||
ClosePrinter(hPrinter);
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to purge printer queue. error (%d)\n", GetLastError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (DeletePrinter(hPrinter) == FALSE)
|
||||
{
|
||||
ClosePrinter(hPrinter);
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to delete printer. error (%d)\n", GetLastError());
|
||||
return;
|
||||
}
|
||||
|
||||
ClosePrinter(hPrinter);
|
||||
}
|
||||
|
||||
bool FileExists(const std::wstring &filePath)
|
||||
{
|
||||
DWORD fileAttributes = GetFileAttributes(filePath.c_str());
|
||||
return (fileAttributes != INVALID_FILE_ATTRIBUTES && !(fileAttributes & FILE_ATTRIBUTE_DIRECTORY));
|
||||
}
|
||||
|
||||
// Steps:
|
||||
// 1. Add the local port.
|
||||
// 2. Check if the driver is installed.
|
||||
// Uninstall the existing driver if it is installed.
|
||||
// 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 infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
|
||||
if (!FileExists(infFile))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Printer driver INF file not found, aborting...\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkAddLocalPort(RD_PRINTER_PORT))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to check add local port, error (%d)\n", GetLastError());
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Local port added successfully\n");
|
||||
}
|
||||
|
||||
if (getInstalledDriverVersion(RD_PRINTER_DRIVER_NAME) > 0)
|
||||
{
|
||||
deletePrinter(RD_PRINTER_NAME);
|
||||
if (FALSE == uninstallDriver(RD_PRINTER_DRIVER_NAME))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to uninstall previous printer driver, error (%d)\n", GetLastError());
|
||||
}
|
||||
}
|
||||
|
||||
if (FALSE == installDriver(RD_PRINTER_DRIVER_NAME, infFile.c_str()))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Driver installation failed, still try to add the printer\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Driver installed successfully\n");
|
||||
}
|
||||
|
||||
if (FALSE == addPrinter(RD_PRINTER_NAME, RD_PRINTER_DRIVER_NAME, RD_PRINTER_PORT))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Failed to add printer, error (%d)\n", GetLastError());
|
||||
}
|
||||
else
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Printer installed successfully\n");
|
||||
}
|
||||
}
|
||||
|
||||
VOID uninstallPrinter()
|
||||
{
|
||||
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(RD_PRINTER_PORT);
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +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]" />
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
|
||||
<InstallExecuteSequence>
|
||||
|
||||
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
||||
@@ -53,8 +54,21 @@
|
||||
<Custom Action="TryDeleteStartupShortcut.SetParam" Before="SetPropertyIsServiceRunning" Condition="STOP_SERVICE="'Y'"" />
|
||||
|
||||
<!-- Launch ClientLauncher if installing or already installed and not uninstalling -->
|
||||
<Custom Action="LaunchApp" After="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE)"/>
|
||||
<Custom Action="LaunchAppTray" After="InstallFinalize" Condition="(NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE)) AND (NOT STOP_SERVICE="'Y'") AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
||||
<!-- https://learn.microsoft.com/en-us/windows/win32/msi/uilevel -->
|
||||
<Custom Action="LaunchApp" After="InstallFinalize" Condition="(NOT UILevel=2) AND (NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE)) "/>
|
||||
<Custom Action="LaunchAppTray" After="InstallFinalize" Condition="(NOT UILevel=2) AND (NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE)) AND (NOT STOP_SERVICE="'Y'") AND (NOT CC_CONNECTION_TYPE="outgoing")"/>
|
||||
|
||||
<!-- https://learn.microsoft.com/en-us/windows/win32/msi/operating-system-property-values -->
|
||||
<!-- We have to use `VersionNT` to instead of `IsWindows10OrGreater()` in the custom action.
|
||||
Because `IsWindows10OrGreater()` requires the manifest file to be embedded in the executable/dll file.
|
||||
Even I have embedded the manifest file, it still does not work correctly in my case.
|
||||
https://learn.microsoft.com/en-us/windows/win32/sysinfo/version-helper-apis -->
|
||||
<!-- VersionNT >= 603 means can't differentiate between Windows 8.1 and Windows 10.
|
||||
Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
|
||||
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
|
||||
<!-- Remote printer also works on Win8.1 in my test. -->
|
||||
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT >= 603 AND PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y"" />
|
||||
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT >= 603" />
|
||||
|
||||
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->
|
||||
<!--ExecFirewallExceptions: Error 0x80070057: failed to add app to the authorized apps list-->
|
||||
@@ -71,6 +85,8 @@
|
||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
<CustomAction Id="SetPropertyServiceStop" DllEntry="SetPropertyFromConfig" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="AddRegSoftwareSASGeneration" DllEntry="AddRegSoftwareSASGeneration" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveAmyuniIdd" DllEntry="RemoveAmyuniIdd" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="InstallPrinter" DllEntry="InstallPrinter" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="UninstallPrinter" DllEntry="UninstallPrinter" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="PRINTER" Value="1" Secure="yes"></Property>
|
||||
|
||||
<!-- These properties get set from either the command line, bundle or registry value,
|
||||
if set they update the properties above with their value. -->
|
||||
@@ -23,6 +24,9 @@
|
||||
<Property Id="CREATEDESKTOPSHORTCUTS" Secure="yes">
|
||||
<RegistrySearch Id="CreateDesktopShortcutsSearch" Root="HKCR" Key="$(var.RegKeyRoot)" Name="DESKTOPSHORTCUTS" Type="raw" />
|
||||
</Property>
|
||||
<Property Id="INSTALLPRINTER" Secure="yes">
|
||||
<RegistrySearch Id="InstallPrinterSearch" Root="HKCR" Key="$(var.RegKeyRoot)" Name="PRINTER" Type="raw" />
|
||||
</Property>
|
||||
|
||||
<!-- Component that persists the property values to the registry so they are available during an upgrade/modify -->
|
||||
<DirectoryRef Id="INSTALLFOLDER_INNER">
|
||||
@@ -46,6 +50,16 @@
|
||||
<RegistryValue Type="string" Name="DESKTOPSHORTCUTS" Value="0" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<Component Id="Product.Registry.PersistedPrinterProperties1" Guid="AF617116-2502-EB3D-5B52-B47AA89EB4B0" Condition="PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y"">
|
||||
<RegistryKey Root="HKCR" Key="$(var.RegKeyRoot)">
|
||||
<RegistryValue Type="string" Name="PRINTER" Value="1" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<Component Id="Product.Registry.PersistedPrinterProperties0" Guid="51F944D3-AAEB-F167-03A1-081A38E9468A" Condition="NOT (PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y")">
|
||||
<RegistryKey Root="HKCR" Key="$(var.RegKeyRoot)">
|
||||
<RegistryValue Type="string" Name="PRINTER" Value="0" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<!-- If a property value has been passed via the command line (which includes when set from the bundle), the registry search will
|
||||
@@ -53,14 +67,17 @@
|
||||
is performed so they can be restored after the registry search is complete -->
|
||||
<SetProperty Id="SavedStartMenuShortcutsCmdLineValue" Value="[CREATESTARTMENUSHORTCUTS]" Before="AppSearch" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS" />
|
||||
<SetProperty Id="SavedDesktopShortcutsCmdLineValue" Value="[CREATEDESKTOPSHORTCUTS]" Before="AppSearch" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS" />
|
||||
<SetProperty Id="SavedPrinterCmdLineValue" Value="[INSTALLPRINTER]" Before="AppSearch" Sequence="first" Condition="INSTALLPRINTER" />
|
||||
|
||||
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
||||
<SetProperty Action="RestoreSavedStartMenuShortcutsValue" Id="CREATESTARTMENUSHORTCUTS" Value="[SavedStartMenuShortcutsCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedStartMenuShortcutsCmdLineValue" />
|
||||
<SetProperty Action="RestoreSavedDesktopShortcutsValue" Id="CREATEDESKTOPSHORTCUTS" Value="[SavedDesktopShortcutsCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedDesktopShortcutsCmdLineValue" />
|
||||
<SetProperty Action="RestoreSavedPrinterValue" Id="INSTALLPRINTER" Value="[SavedPrinterCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedPrinterCmdLineValue" />
|
||||
|
||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
||||
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = "Y" OR CREATESTARTMENUSHORTCUTS = "y")" />
|
||||
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = "Y" OR CREATEDESKTOPSHORTCUTS = "y")" />
|
||||
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y")" />
|
||||
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
||||
@@ -51,5 +51,6 @@ This file contains the declaration of all the localizable strings.
|
||||
|
||||
<String Id="MyInstallDirDlgDesktopShortcuts" Value="Create desktop icon" />
|
||||
<String Id="MyInstallDirDlgStartMenuShortcuts" Value="Create start menu shortcuts" />
|
||||
<String Id="MyInstallDirDlgPrinter" Value="Install RustDesk Printer" />
|
||||
|
||||
</WixLocalization>
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
<ComponentRef Id="Product.Registry.PersistedStartMenuShortcutProperties0" />
|
||||
<ComponentRef Id="Product.Registry.PersistedDesktopShortcutProperties1" />
|
||||
<ComponentRef Id="Product.Registry.PersistedDesktopShortcutProperties0" />
|
||||
<ComponentRef Id="Product.Registry.PersistedPrinterProperties1" />
|
||||
<ComponentRef Id="Product.Registry.PersistedPrinterProperties0" />
|
||||
</Feature>
|
||||
|
||||
<!--https://wixtoolset.org/docs/tools/wixext/wixui/#customizing-a-dialog-set-->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<Control Id="ChkBoxStartMenuShortcuts" Type="CheckBox" X="20" Y="140" Width="290" Height="17" Property="STARTMENUSHORTCUTS" CheckBoxValue="1" Text="!(loc.MyInstallDirDlgStartMenuShortcuts)" />
|
||||
<Control Id="ChkBoxDesktopShortcuts" Type="CheckBox" X="20" Y="160" Width="290" Height="17" Property="DESKTOPSHORTCUTS" CheckBoxValue="1" Text="!(loc.MyInstallDirDlgDesktopShortcuts)" />
|
||||
<Control Id="ChkBoxInstallPrinter" Type="CheckBox" X="20" Y="180" Width="290" Height="17" Property="PRINTER" CheckBoxValue="1" Text="!(loc.MyInstallDirDlgPrinter)" />
|
||||
</Dialog>
|
||||
</UI>
|
||||
</Fragment>
|
||||
|
||||
@@ -8,7 +8,9 @@ import argparse
|
||||
import datetime
|
||||
import subprocess
|
||||
import re
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
import shutil
|
||||
|
||||
g_indent_unit = "\t"
|
||||
@@ -187,6 +189,17 @@ 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):
|
||||
@@ -542,3 +555,4 @@ if __name__ == "__main__":
|
||||
sys.exit(-1)
|
||||
|
||||
replace_app_name_in_langs(args.app_name)
|
||||
replace_app_name_in_custom_actions(args.app_name)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.3.8
|
||||
Version: 1.3.9
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.3.8
|
||||
Version: 1.3.9
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.3.8
|
||||
Version: 1.3.9
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -49,6 +49,7 @@ use hbb_common::{
|
||||
self, Config, LocalConfig, PeerConfig, PeerInfoSerde, Resolution, CONNECT_TIMEOUT,
|
||||
READ_TIMEOUT, RELAY_PORT, RENDEZVOUS_PORT, RENDEZVOUS_SERVERS,
|
||||
},
|
||||
fs::JobType,
|
||||
get_version_number, log,
|
||||
message_proto::{option_message::BoolOption, *},
|
||||
protobuf::{Message as _, MessageField},
|
||||
@@ -848,6 +849,10 @@ impl ClientClipboardHandler {
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Client, false) {
|
||||
if !urls.is_empty() {
|
||||
#[cfg(target_os = "macos")]
|
||||
if crate::clipboard::is_file_url_set_by_rustdesk(&urls) {
|
||||
return;
|
||||
}
|
||||
if self.is_file_required() {
|
||||
match clipboard::platform::unix::serv_files::sync_files(&urls) {
|
||||
Ok(()) => {
|
||||
@@ -890,7 +895,8 @@ impl ClientClipboardHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(pi) = ctx.cfg.lc.read().unwrap().peer_info.as_ref() {
|
||||
let pi = ctx.cfg.lc.read().unwrap().peer_info.clone();
|
||||
if let Some(pi) = pi.as_ref() {
|
||||
if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union {
|
||||
if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip(
|
||||
&pi.version,
|
||||
@@ -1384,14 +1390,15 @@ impl VideoHandler {
|
||||
}
|
||||
|
||||
/// Start or stop screen record.
|
||||
pub fn record_screen(&mut self, start: bool, id: String, display: usize) {
|
||||
pub fn record_screen(&mut self, start: bool, id: String, display_idx: usize, camera: bool) {
|
||||
self.record = false;
|
||||
if start {
|
||||
self.recorder = Recorder::new(RecorderContext {
|
||||
server: false,
|
||||
id,
|
||||
dir: crate::ui_interface::video_save_directory(false),
|
||||
display,
|
||||
display_idx,
|
||||
camera,
|
||||
tx: None,
|
||||
})
|
||||
.map_or(Default::default(), |r| Arc::new(Mutex::new(Some(r))));
|
||||
@@ -2344,6 +2351,7 @@ impl LoginConfigHandler {
|
||||
show_hidden: !self.get_option("remote_show_hidden").is_empty(),
|
||||
..Default::default()
|
||||
}),
|
||||
ConnType::VIEW_CAMERA => lr.set_view_camera(Default::default()),
|
||||
ConnType::PORT_FORWARD | ConnType::RDP => lr.set_port_forward(PortForward {
|
||||
host: self.port_forward.0.clone(),
|
||||
port: self.port_forward.1,
|
||||
@@ -2431,6 +2439,7 @@ pub fn start_video_thread<F, T>(
|
||||
{
|
||||
let mut video_callback = video_callback;
|
||||
let mut last_chroma = None;
|
||||
let is_view_camera = session.is_view_camera();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
#[cfg(windows)]
|
||||
@@ -2473,7 +2482,7 @@ pub fn start_video_thread<F, T>(
|
||||
let record_permission = session.lc.read().unwrap().record_permission;
|
||||
let id = session.lc.read().unwrap().id.clone();
|
||||
if record_state && record_permission {
|
||||
handler.record_screen(true, id, display);
|
||||
handler.record_screen(true, id, display, is_view_camera);
|
||||
}
|
||||
video_handler = Some(handler);
|
||||
}
|
||||
@@ -2554,7 +2563,7 @@ pub fn start_video_thread<F, T>(
|
||||
MediaData::RecordScreen(start) => {
|
||||
let id = session.lc.read().unwrap().id.clone();
|
||||
if let Some(handler) = video_handler.as_mut() {
|
||||
handler.record_screen(start, id, display);
|
||||
handler.record_screen(start, id, display, is_view_camera);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3289,7 +3298,7 @@ pub enum Data {
|
||||
Close,
|
||||
Login((String, String, String, bool)),
|
||||
Message(Message),
|
||||
SendFiles((i32, String, String, i32, bool, bool)),
|
||||
SendFiles((i32, JobType, String, String, i32, bool, bool)),
|
||||
RemoveDirAll((i32, String, bool, bool)),
|
||||
ConfirmDeleteFiles((i32, i32)),
|
||||
SetNoConfirm(i32),
|
||||
@@ -3303,7 +3312,7 @@ pub enum Data {
|
||||
ToggleClipboardFile,
|
||||
NewRDP,
|
||||
SetConfirmOverrideFile((i32, i32, bool, bool, bool)),
|
||||
AddJob((i32, String, String, i32, bool, bool)),
|
||||
AddJob((i32, JobType, String, String, i32, bool, bool)),
|
||||
ResumeJob((i32, bool)),
|
||||
RecordScreen(bool),
|
||||
ElevateDirect,
|
||||
|
||||
@@ -7,6 +7,14 @@ pub trait FileManager: Interface {
|
||||
fs::get_home_as_string()
|
||||
}
|
||||
|
||||
fn get_next_job_id(&self) -> i32 {
|
||||
fs::get_next_job_id()
|
||||
}
|
||||
|
||||
fn update_next_job_id(&self, id: i32) {
|
||||
fs::update_next_job_id(id);
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "android",
|
||||
target_os = "ios",
|
||||
@@ -98,6 +106,7 @@ pub trait FileManager: Interface {
|
||||
fn send_files(
|
||||
&self,
|
||||
id: i32,
|
||||
r#type: i32,
|
||||
path: String,
|
||||
to: String,
|
||||
file_num: i32,
|
||||
@@ -106,6 +115,7 @@ pub trait FileManager: Interface {
|
||||
) {
|
||||
self.send(Data::SendFiles((
|
||||
id,
|
||||
r#type.into(),
|
||||
path,
|
||||
to,
|
||||
file_num,
|
||||
@@ -117,6 +127,7 @@ pub trait FileManager: Interface {
|
||||
fn add_job(
|
||||
&self,
|
||||
id: i32,
|
||||
r#type: i32,
|
||||
path: String,
|
||||
to: String,
|
||||
file_num: i32,
|
||||
@@ -125,6 +136,7 @@ pub trait FileManager: Interface {
|
||||
) {
|
||||
self.send(Data::AddJob((
|
||||
id,
|
||||
r#type.into(),
|
||||
path,
|
||||
to,
|
||||
file_num,
|
||||
|
||||
@@ -12,7 +12,10 @@ use crate::{
|
||||
};
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
use clipboard::ContextSend;
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
@@ -43,6 +46,7 @@ use std::{
|
||||
collections::HashMap,
|
||||
ffi::c_void,
|
||||
num::NonZeroI64,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc, RwLock,
|
||||
@@ -80,6 +84,7 @@ struct ParsedPeerInfo {
|
||||
platform: String,
|
||||
is_installed: bool,
|
||||
idd_impl: String,
|
||||
support_view_camera: bool,
|
||||
}
|
||||
|
||||
impl ParsedPeerInfo {
|
||||
@@ -126,7 +131,10 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _file_clip_context_holder = {
|
||||
// `is_port_forward()` will not reach here, but we still check it for clarity.
|
||||
if !self.handler.is_file_transfer() && !self.handler.is_port_forward() {
|
||||
if !self.handler.is_file_transfer()
|
||||
&& !self.handler.is_port_forward()
|
||||
&& !self.handler.is_view_camera()
|
||||
{
|
||||
// It is ok to call this function multiple times.
|
||||
ContextSend::enable(true);
|
||||
Some(crate::SimpleCallOnReturn {
|
||||
@@ -149,6 +157,8 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
let mut received = false;
|
||||
let conn_type = if self.handler.is_file_transfer() {
|
||||
ConnType::FILE_TRANSFER
|
||||
} else if self.handler.is_view_camera() {
|
||||
ConnType::VIEW_CAMERA
|
||||
} else {
|
||||
ConnType::default()
|
||||
};
|
||||
@@ -170,7 +180,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.set_connected();
|
||||
self.handler.set_connection_type(peer.is_secured(), direct); // flutter -> connection_ready
|
||||
self.handler.update_direct(Some(direct));
|
||||
if conn_type == ConnType::DEFAULT_CONN {
|
||||
if conn_type == ConnType::DEFAULT_CONN || conn_type == ConnType::VIEW_CAMERA {
|
||||
self.handler
|
||||
.set_fingerprint(crate::common::pk_to_fingerprint(pk.unwrap_or_default()));
|
||||
}
|
||||
@@ -187,7 +197,8 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
{
|
||||
let is_conn_not_default = self.handler.is_file_transfer()
|
||||
|| self.handler.is_port_forward()
|
||||
|| self.handler.is_rdp();
|
||||
|| self.handler.is_rdp()
|
||||
|| self.handler.is_view_camera();
|
||||
if !is_conn_not_default {
|
||||
(self.client_conn_id, rx_clip_client_holder.0) =
|
||||
clipboard::get_rx_cliprdr_client(&self.handler.get_id());
|
||||
@@ -327,12 +338,12 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.set_disconnected(round);
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
if _set_disconnected_ok {
|
||||
if !self.handler.is_view_camera() && _set_disconnected_ok {
|
||||
Client::try_stop_clipboard();
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
if _set_disconnected_ok {
|
||||
if !self.handler.is_view_camera() && _set_disconnected_ok {
|
||||
crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id);
|
||||
}
|
||||
}
|
||||
@@ -539,13 +550,20 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
allow_err!(peer.send(&msg).await);
|
||||
}
|
||||
Data::SendFiles((id, path, to, file_num, include_hidden, is_remote)) => {
|
||||
Data::SendFiles((id, r#type, path, to, file_num, include_hidden, is_remote)) => {
|
||||
log::info!("send files, is remote {}", is_remote);
|
||||
let od = can_enable_overwrite_detection(self.handler.lc.read().unwrap().version);
|
||||
if is_remote {
|
||||
log::debug!("New job {}, write to {} from remote {}", id, to, path);
|
||||
let to = match r#type {
|
||||
fs::JobType::Generic => fs::DataSource::FilePath(PathBuf::from(&to)),
|
||||
fs::JobType::Printer => {
|
||||
fs::DataSource::MemoryCursor(std::io::Cursor::new(Vec::new()))
|
||||
}
|
||||
};
|
||||
self.write_jobs.push(fs::TransferJob::new_write(
|
||||
id,
|
||||
r#type,
|
||||
path.clone(),
|
||||
to,
|
||||
file_num,
|
||||
@@ -555,14 +573,15 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
od,
|
||||
));
|
||||
allow_err!(
|
||||
peer.send(&fs::new_send(id, path, file_num, include_hidden))
|
||||
peer.send(&fs::new_send(id, r#type, path, file_num, include_hidden))
|
||||
.await
|
||||
);
|
||||
} else {
|
||||
match fs::TransferJob::new_read(
|
||||
id,
|
||||
r#type,
|
||||
to.clone(),
|
||||
path.clone(),
|
||||
fs::DataSource::FilePath(PathBuf::from(&path)),
|
||||
file_num,
|
||||
include_hidden,
|
||||
is_remote,
|
||||
@@ -606,7 +625,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Data::AddJob((id, path, to, file_num, include_hidden, is_remote)) => {
|
||||
Data::AddJob((id, r#type, path, to, file_num, include_hidden, is_remote)) => {
|
||||
let od = can_enable_overwrite_detection(self.handler.lc.read().unwrap().version);
|
||||
if is_remote {
|
||||
log::debug!(
|
||||
@@ -617,8 +636,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
);
|
||||
let mut job = fs::TransferJob::new_write(
|
||||
id,
|
||||
r#type,
|
||||
path.clone(),
|
||||
to,
|
||||
fs::DataSource::FilePath(PathBuf::from(&to)),
|
||||
file_num,
|
||||
include_hidden,
|
||||
is_remote,
|
||||
@@ -630,8 +650,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
} else {
|
||||
match fs::TransferJob::new_read(
|
||||
id,
|
||||
r#type,
|
||||
to.clone(),
|
||||
path.clone(),
|
||||
fs::DataSource::FilePath(PathBuf::from(&path)),
|
||||
file_num,
|
||||
include_hidden,
|
||||
is_remote,
|
||||
@@ -669,6 +690,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
allow_err!(
|
||||
peer.send(&fs::new_send(
|
||||
id,
|
||||
fs::JobType::Generic,
|
||||
job.remote.clone(),
|
||||
job.file_num,
|
||||
job.show_hidden
|
||||
@@ -678,17 +700,25 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
} else {
|
||||
if let Some(job) = get_job(id, &mut self.read_jobs) {
|
||||
job.is_last_job = false;
|
||||
allow_err!(
|
||||
peer.send(&fs::new_receive(
|
||||
id,
|
||||
job.path.to_string_lossy().to_string(),
|
||||
job.file_num,
|
||||
job.files.clone(),
|
||||
job.total_size(),
|
||||
))
|
||||
.await
|
||||
);
|
||||
match &job.data_source {
|
||||
fs::DataSource::FilePath(p) => {
|
||||
job.is_last_job = false;
|
||||
allow_err!(
|
||||
peer.send(&fs::new_receive(
|
||||
id,
|
||||
p.to_string_lossy().to_string(),
|
||||
job.file_num,
|
||||
job.files.clone(),
|
||||
job.total_size(),
|
||||
))
|
||||
.await
|
||||
);
|
||||
}
|
||||
fs::DataSource::MemoryCursor(_) => {
|
||||
// unreachable!()
|
||||
log::error!("Resume job with memory cursor");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,11 +823,10 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
});
|
||||
msg_out.set_file_action(file_action);
|
||||
allow_err!(peer.send(&msg_out).await);
|
||||
if let Some(job) = fs::get_job(id, &mut self.write_jobs) {
|
||||
if let Some(job) = fs::remove_job(id, &mut self.write_jobs) {
|
||||
job.remove_download_file();
|
||||
fs::remove_job(id, &mut self.write_jobs);
|
||||
}
|
||||
fs::remove_job(id, &mut self.read_jobs);
|
||||
let _ = fs::remove_job(id, &mut self.read_jobs);
|
||||
self.remove_jobs.remove(&id);
|
||||
}
|
||||
Data::RemoveDir((id, path)) => {
|
||||
@@ -1173,6 +1202,25 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_view_camera_support(&self, peer_version: &str, peer_platform: &str) -> bool {
|
||||
if self.peer_info.support_view_camera {
|
||||
return true;
|
||||
}
|
||||
if hbb_common::get_version_number(&peer_version) < hbb_common::get_version_number("1.3.9")
|
||||
&& (peer_platform == "Windows" || peer_platform == "Linux")
|
||||
{
|
||||
self.handler.msgbox(
|
||||
"error",
|
||||
"Download new version",
|
||||
"upgrade_remote_rustdesk_client_to_{1.3.9}_tip",
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
self.handler.on_error("view_camera_unsupported_tip");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fn handle_msg_from_peer(&mut self, data: &[u8], peer: &mut Stream) -> bool {
|
||||
if let Ok(msg_in) = Message::parse_from_bytes(&data) {
|
||||
match msg_in.union {
|
||||
@@ -1227,10 +1275,19 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
let peer_version = pi.version.clone();
|
||||
let peer_platform = pi.platform.clone();
|
||||
self.set_peer_info(&pi);
|
||||
if self.handler.is_view_camera() {
|
||||
if !self.check_view_camera_support(&peer_version, &peer_platform) {
|
||||
self.handler.lc.write().unwrap().handle_peer_info(&pi);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.handler.handle_peer_info(pi);
|
||||
#[cfg(all(target_os = "windows", not(feature = "flutter")))]
|
||||
self.check_clipboard_file_context();
|
||||
if !(self.handler.is_file_transfer() || self.handler.is_port_forward()) {
|
||||
if !(self.handler.is_file_transfer()
|
||||
|| self.handler.is_port_forward()
|
||||
|| self.handler.is_view_camera())
|
||||
{
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
let rx = Client::try_start_clipboard(None);
|
||||
@@ -1364,92 +1421,105 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
if digest.is_upload {
|
||||
if let Some(job) = fs::get_job(digest.id, &mut self.read_jobs) {
|
||||
if let Some(file) = job.files().get(digest.file_num as usize) {
|
||||
let read_path = get_string(&job.join(&file.name));
|
||||
let overwrite_strategy = job.default_overwrite_strategy();
|
||||
if let Some(overwrite) = overwrite_strategy {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(if overwrite {
|
||||
file_transfer_send_confirm_request::Union::OffsetBlk(0)
|
||||
} else {
|
||||
file_transfer_send_confirm_request::Union::Skip(
|
||||
true,
|
||||
)
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
} else {
|
||||
self.handler.override_file_confirm(
|
||||
digest.id,
|
||||
digest.file_num,
|
||||
read_path,
|
||||
true,
|
||||
digest.is_identical,
|
||||
);
|
||||
if let fs::DataSource::FilePath(p) = &job.data_source {
|
||||
let read_path =
|
||||
get_string(&fs::TransferJob::join(p, &file.name));
|
||||
let overwrite_strategy =
|
||||
job.default_overwrite_strategy();
|
||||
if let Some(overwrite) = overwrite_strategy {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(if overwrite {
|
||||
file_transfer_send_confirm_request::Union::OffsetBlk(0)
|
||||
} else {
|
||||
file_transfer_send_confirm_request::Union::Skip(
|
||||
true,
|
||||
)
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
} else {
|
||||
self.handler.override_file_confirm(
|
||||
digest.id,
|
||||
digest.file_num,
|
||||
read_path,
|
||||
true,
|
||||
digest.is_identical,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(job) = fs::get_job(digest.id, &mut self.write_jobs) {
|
||||
if let Some(file) = job.files().get(digest.file_num as usize) {
|
||||
let write_path = get_string(&job.join(&file.name));
|
||||
let overwrite_strategy = job.default_overwrite_strategy();
|
||||
match fs::is_write_need_confirmation(&write_path, &digest) {
|
||||
Ok(res) => match res {
|
||||
DigestCheckResult::IsSame => {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
if let fs::DataSource::FilePath(p) = &job.data_source {
|
||||
let write_path =
|
||||
get_string(&fs::TransferJob::join(p, &file.name));
|
||||
let overwrite_strategy =
|
||||
job.default_overwrite_strategy();
|
||||
match fs::is_write_need_confirmation(
|
||||
&write_path,
|
||||
&digest,
|
||||
) {
|
||||
Ok(res) => match res {
|
||||
DigestCheckResult::IsSame => {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(file_transfer_send_confirm_request::Union::Skip(true)),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
}
|
||||
DigestCheckResult::NeedConfirm(digest) => {
|
||||
if let Some(overwrite) = overwrite_strategy {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(if overwrite {
|
||||
file_transfer_send_confirm_request::Union::OffsetBlk(0)
|
||||
} else {
|
||||
file_transfer_send_confirm_request::Union::Skip(true)
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
} else {
|
||||
self.handler.override_file_confirm(
|
||||
digest.id,
|
||||
digest.file_num,
|
||||
write_path,
|
||||
false,
|
||||
digest.is_identical,
|
||||
);
|
||||
}
|
||||
}
|
||||
DigestCheckResult::NoSuchFile => {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
DigestCheckResult::NeedConfirm(digest) => {
|
||||
if let Some(overwrite) = overwrite_strategy
|
||||
{
|
||||
let req =
|
||||
FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(if overwrite {
|
||||
file_transfer_send_confirm_request::Union::OffsetBlk(0)
|
||||
} else {
|
||||
file_transfer_send_confirm_request::Union::Skip(true)
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
} else {
|
||||
self.handler.override_file_confirm(
|
||||
digest.id,
|
||||
digest.file_num,
|
||||
write_path,
|
||||
false,
|
||||
digest.is_identical,
|
||||
);
|
||||
}
|
||||
}
|
||||
DigestCheckResult::NoSuchFile => {
|
||||
let req = FileTransferSendConfirmRequest {
|
||||
id: digest.id,
|
||||
file_num: digest.file_num,
|
||||
union: Some(file_transfer_send_confirm_request::Union::OffsetBlk(0)),
|
||||
..Default::default()
|
||||
};
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
job.confirm(&req);
|
||||
let msg = new_send_confirm(req);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
println!("error receiving digest: {}", err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
println!("error receiving digest: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1461,23 +1531,76 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
if let Err(_err) = job.write(block).await {
|
||||
// to-do: add "skip" for writing job
|
||||
}
|
||||
self.update_jobs_status();
|
||||
if job.r#type == fs::JobType::Generic {
|
||||
self.update_jobs_status();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(file_response::Union::Done(d)) => {
|
||||
let mut err: Option<String> = None;
|
||||
if let Some(job) = fs::get_job(d.id, &mut self.write_jobs) {
|
||||
let mut job_type = fs::JobType::Generic;
|
||||
let mut printer_data = None;
|
||||
if let Some(job) = fs::remove_job(d.id, &mut self.write_jobs) {
|
||||
job.modify_time();
|
||||
err = job.job_error();
|
||||
fs::remove_job(d.id, &mut self.write_jobs);
|
||||
job_type = job.r#type;
|
||||
printer_data = match job.get_buf_data().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get the printer data: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
match job_type {
|
||||
fs::JobType::Generic => {
|
||||
self.handle_job_status(d.id, d.file_num, err);
|
||||
}
|
||||
fs::JobType::Printer => {
|
||||
if let Some(err) = err {
|
||||
log::error!("Receive print job failed, error {err}");
|
||||
} else {
|
||||
log::info!(
|
||||
"Receive print job done, data len: {:?}",
|
||||
printer_data.as_ref().map(|d| d.len()).unwrap_or(0)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(data) = printer_data {
|
||||
let printer_name = self
|
||||
.handler
|
||||
.printer_names
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove(&d.id);
|
||||
// Spawn a new thread to handle the print job.
|
||||
// Or print job will block the ui thread.
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) =
|
||||
crate::platform::send_raw_data_to_printer(
|
||||
printer_name,
|
||||
data,
|
||||
)
|
||||
{
|
||||
log::error!("Print job error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.handle_job_status(d.id, d.file_num, err);
|
||||
}
|
||||
Some(file_response::Union::Error(e)) => {
|
||||
if let Some(_job) = fs::get_job(e.id, &mut self.write_jobs) {
|
||||
fs::remove_job(e.id, &mut self.write_jobs);
|
||||
let job_type = fs::remove_job(e.id, &mut self.write_jobs)
|
||||
.map(|j| j.r#type)
|
||||
.unwrap_or(fs::JobType::Generic);
|
||||
match job_type {
|
||||
fs::JobType::Generic => {
|
||||
self.handle_job_status(e.id, e.file_num, Some(e.error));
|
||||
}
|
||||
fs::JobType::Printer => {
|
||||
log::error!("Printer job error: {}", e.error);
|
||||
}
|
||||
}
|
||||
self.handle_job_status(e.id, e.file_num, Some(e.error));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1529,6 +1652,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Permission::Camera) => {
|
||||
self.handler.set_permission("camera", p.enabled);
|
||||
}
|
||||
Ok(Permission::Restart) => {
|
||||
self.handler.set_permission("restart", p.enabled);
|
||||
}
|
||||
@@ -1698,6 +1824,41 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
Some(message::Union::FileAction(action)) => match action.union {
|
||||
Some(file_action::Union::Send(_s)) => match _s.file_type.enum_value() {
|
||||
#[cfg(target_os = "windows")]
|
||||
Ok(file_transfer_send_request::FileType::Printer) => {
|
||||
#[cfg(feature = "flutter")]
|
||||
let action = LocalConfig::get_option(
|
||||
config::keys::OPTION_PRINTER_INCOMING_JOB_ACTION,
|
||||
);
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
let action = "";
|
||||
if action == "dismiss" {
|
||||
// Just ignore the incoming print job.
|
||||
} else {
|
||||
let id = fs::get_next_job_id();
|
||||
#[cfg(feature = "flutter")]
|
||||
let allow_auto_print = LocalConfig::get_bool_option(
|
||||
config::keys::OPTION_PRINTER_ALLOW_AUTO_PRINT,
|
||||
);
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
let allow_auto_print = false;
|
||||
if allow_auto_print {
|
||||
let printer_name = if action == "" {
|
||||
"".to_string()
|
||||
} else {
|
||||
LocalConfig::get_option(
|
||||
config::keys::OPTION_PRINTER_SELECTED_NAME,
|
||||
)
|
||||
};
|
||||
self.handler.printer_response(id, _s.path, printer_name);
|
||||
} else {
|
||||
self.handler.printer_request(id, _s.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Some(file_action::Union::SendConfirm(c)) => {
|
||||
if let Some(job) = fs::get_job(c.id, &mut self.read_jobs) {
|
||||
job.confirm(&c);
|
||||
@@ -1770,6 +1931,11 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
self.peer_info.support_view_camera = platform_additions
|
||||
.get("support_view_camera")
|
||||
.map(|v| v.as_bool())
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1956,7 +2122,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
async fn handle_cliprdr_msg(
|
||||
&self,
|
||||
&mut self,
|
||||
clip: hbb_common::message_proto::Cliprdr,
|
||||
_peer: &mut Stream,
|
||||
) {
|
||||
@@ -1982,7 +2148,10 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
"Process clipboard message from server peer, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}",
|
||||
stop, is_stopping_allowed, file_transfer_enabled);
|
||||
if !stop {
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
if let Err(e) = ContextSend::make_sure_enabled() {
|
||||
log::error!("failed to restart clipboard context: {}", e);
|
||||
};
|
||||
@@ -1996,11 +2165,35 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
if crate::is_support_file_copy_paste_num(self.handler.lc.read().unwrap().version) {
|
||||
if let Some(msg) = unix_file_clip::serve_clip_messages(
|
||||
ClipboardSide::Client,
|
||||
clip,
|
||||
self.client_conn_id,
|
||||
) {
|
||||
let mut out_msg = None;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if clipboard::platform::unix::macos::should_handle_msg(&clip) {
|
||||
if let Err(e) = ContextSend::proc(|context| -> ResultType<()> {
|
||||
context
|
||||
.server_clip_file(self.client_conn_id, clip)
|
||||
.map_err(|e| e.into())
|
||||
}) {
|
||||
log::error!("failed to handle cliprdr msg: {}", e);
|
||||
}
|
||||
} else {
|
||||
out_msg = unix_file_clip::serve_clip_messages(
|
||||
ClipboardSide::Client,
|
||||
clip,
|
||||
self.client_conn_id,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
out_msg = unix_file_clip::serve_clip_messages(
|
||||
ClipboardSide::Client,
|
||||
clip,
|
||||
self.client_conn_id,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(msg) = out_msg {
|
||||
allow_err!(_peer.send(&msg).await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ pub fn check_clipboard(
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))]
|
||||
pub fn is_file_url_set_by_rustdesk(url: &Vec<String>) -> bool {
|
||||
if url.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
url.iter()
|
||||
.next()
|
||||
.map(|s| {
|
||||
for prefix in &["file:///tmp/.rustdesk_", "//tmp/.rustdesk_"] {
|
||||
if s.starts_with(prefix) {
|
||||
return s[prefix.len()..].parse::<uuid::Uuid>().is_ok();
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
pub fn check_clipboard_files(
|
||||
ctx: &mut Option<ClipboardContext>,
|
||||
@@ -110,7 +128,6 @@ pub fn update_clipboard_files(files: Vec<String>, side: ClipboardSide) {
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) {
|
||||
#[cfg(target_os = "linux")]
|
||||
std::thread::spawn(move || {
|
||||
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
|
||||
if ctx.is_none() {
|
||||
@@ -125,9 +142,22 @@ pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) {
|
||||
}
|
||||
}
|
||||
if let Some(mut ctx) = ctx.as_mut() {
|
||||
use clipboard::platform::unix;
|
||||
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use clipboard::platform::unix;
|
||||
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
// No need to make sure the context is enabled.
|
||||
clipboard::ContextSend::proc(|context| -> ResultType<()> {
|
||||
context.empty_clipboard(_conn_id).ok();
|
||||
Ok(())
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -351,27 +381,43 @@ impl ClipboardContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))]
|
||||
fn get_file_urls_set_by_rustdesk(
|
||||
data: Vec<ClipboardData>,
|
||||
_side: ClipboardSide,
|
||||
) -> Vec<String> {
|
||||
for item in data.into_iter() {
|
||||
if let ClipboardData::FileUrl(urls) = item {
|
||||
if is_file_url_set_by_rustdesk(&urls) {
|
||||
return urls;
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
|
||||
fn get_file_urls_set_by_rustdesk(data: Vec<ClipboardData>, side: ClipboardSide) -> Vec<String> {
|
||||
let exclude_path =
|
||||
clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client);
|
||||
data.into_iter()
|
||||
.filter_map(|c| match c {
|
||||
ClipboardData::FileUrl(urls) => Some(
|
||||
urls.into_iter()
|
||||
.filter(|s| s.starts_with(&*exclude_path))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
fn try_empty_clipboard_files(&mut self, side: ClipboardSide) {
|
||||
let _lock = ARBOARD_MTX.lock().unwrap();
|
||||
if let Ok(data) = self.get_formats(&[ClipboardFormat::FileUrl]) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let exclude_path =
|
||||
clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client);
|
||||
#[cfg(target_os = "macos")]
|
||||
let exclude_path: Arc<String> = Default::default();
|
||||
let urls = data
|
||||
.into_iter()
|
||||
.filter_map(|c| match c {
|
||||
ClipboardData::FileUrl(urls) => Some(
|
||||
urls.into_iter()
|
||||
.filter(|s| s.starts_with(&*exclude_path))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
let urls = Self::get_file_urls_set_by_rustdesk(data, side);
|
||||
if !urls.is_empty() {
|
||||
// FIXME:
|
||||
// The host-side clear file clipboard `let _ = self.inner.clear();`,
|
||||
|
||||
@@ -139,6 +139,14 @@ pub fn is_support_file_copy_paste_num(ver: i64) -> bool {
|
||||
ver >= hbb_common::get_version_number("1.3.8")
|
||||
}
|
||||
|
||||
pub fn is_support_remote_print(ver: &str) -> bool {
|
||||
hbb_common::get_version_number(ver) >= hbb_common::get_version_number("1.3.9")
|
||||
}
|
||||
|
||||
pub fn is_support_file_paste_if_macos(ver: &str) -> bool {
|
||||
hbb_common::get_version_number(ver) >= hbb_common::get_version_number("1.3.9")
|
||||
}
|
||||
|
||||
// is server process, with "--server" args
|
||||
#[inline]
|
||||
pub fn is_server() -> bool {
|
||||
@@ -822,7 +830,13 @@ pub fn is_modifier(evt: &KeyEvent) -> bool {
|
||||
}
|
||||
|
||||
pub fn check_software_update() {
|
||||
std::thread::spawn(move || allow_err!(check_software_update_()));
|
||||
if is_custom_client() {
|
||||
return;
|
||||
}
|
||||
let opt = config::LocalConfig::get_option(config::keys::OPTION_ENABLE_CHECK_UPDATE);
|
||||
if config::option2bool(config::keys::OPTION_ENABLE_CHECK_UPDATE, &opt) {
|
||||
std::thread::spawn(move || allow_err!(check_software_update_()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
@@ -1721,9 +1735,17 @@ pub fn get_builtin_option(key: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_custom_client() -> bool {
|
||||
get_app_name() != "RustDesk"
|
||||
}
|
||||
|
||||
pub fn verify_login(raw: &str, id: &str) -> bool {
|
||||
true
|
||||
/*
|
||||
if is_custom_client() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
return true;
|
||||
let Ok(pk) = crate::decode64("IycjQd4TmWvjjLnYd796Rd+XkK+KG+7GU1Ia7u4+vSw=") else {
|
||||
|
||||
@@ -53,6 +53,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
"--connect",
|
||||
"--play",
|
||||
"--file-transfer",
|
||||
"--view-camera",
|
||||
"--port-forward",
|
||||
"--rdp",
|
||||
]
|
||||
@@ -99,7 +100,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if args.contains(&"--connect".to_string()) {
|
||||
if args.contains(&"--connect".to_string()) || args.contains(&"--view-camera".to_string()) {
|
||||
hbb_common::platform::windows::start_cpu_performance_monitor();
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
@@ -195,12 +196,11 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
if config::is_disable_installation() {
|
||||
return None;
|
||||
}
|
||||
let res = platform::install_me(
|
||||
"desktopicon startmenu",
|
||||
"".to_owned(),
|
||||
true,
|
||||
args.len() > 1,
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
let options = "desktopicon startmenu";
|
||||
#[cfg(windows)]
|
||||
let options = "desktopicon startmenu printer";
|
||||
let res = platform::install_me(options, "".to_owned(), true, args.len() > 1);
|
||||
let text = match res {
|
||||
Ok(_) => translate("Installation Successful!".to_string()),
|
||||
Err(err) => {
|
||||
@@ -589,7 +589,7 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option<Vec<Strin
|
||||
let mut param_array = vec![];
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--connect" | "--play" | "--file-transfer" | "--port-forward" | "--rdp" => {
|
||||
"--connect" | "--play" | "--file-transfer" | "--view-camera" | "--port-forward" | "--rdp" => {
|
||||
authority = Some((&arg.to_string()[2..]).to_owned());
|
||||
id = args.next();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user