Compare commits

...

64 Commits
1.3.8 ... 1.3.9

Author SHA1 Message Date
Kleofass
3dbe27ea57 Update lv.rs (#11298) 2025-03-31 13:46:38 +08:00
Mahdi Rahimi
a2725df7cd Update Arabic translation in ar.rs (#11281) 2025-03-31 13:46:25 +08:00
Mahdi Rahimi
f32988b454 Updated Persian translations in fa.rs (#11280) 2025-03-31 13:46:15 +08:00
bovirus
adf83a1b25 Italian language update (#11278) 2025-03-31 13:45:59 +08:00
Mr-Update
ea74ed12b8 Update de.rs (#11269) 2025-03-30 14:45:17 +08:00
Lynilia
5f3b980373 Update fr.rs (#11266) 2025-03-29 21:55:28 +08:00
fufesou
23e70c0fd1 refact: remote printer, adapter dll, free data ptr (#11279)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-28 21:58:46 +08:00
fufesou
4b14f86134 refact: remote printer, log (#11271)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-28 10:56:19 +08:00
fufesou
ee2478168c fix: remote printer (#11270)
* fix: remote printer, log

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: remote printer, avoid double sign

Signed-off-by: fufesou <linlong1266@gmail.com>

* Spawn a new thread to handle the print job.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-28 10:36:42 +08:00
fufesou
f4bbf82363 feat: remote printer (#11231)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-27 15:34:27 +08:00
Kleofass
1cb53c1f7a Update lv.rs (#11250) 2025-03-27 14:42:31 +08:00
RustDesk
eea9e0fa43 Update README-DE.md (#11247) 2025-03-26 15:23:40 +08:00
RustDesk
ac630c2ca6 Update README-ZH.md 2025-03-25 15:41:36 +08:00
RustDesk
9831f93430 Update README.md 2025-03-24 21:59:08 +08:00
Lynilia
c074a1d6af Update fr.rs (#11184) 2025-03-20 15:12:03 +08:00
asereze
47c93f8544 Update sc.rs (#11176)
* Update sc.rs

* Update sc.rs
2025-03-19 22:26:14 +08:00
rustdesk
c06ac9341a improve check_software_update 2025-03-18 15:12:41 +08:00
Alex Rijckaert
8d231b4605 Update nl.rs (#11172) 2025-03-18 14:48:48 +08:00
Alex Rijckaert
745ba1673d Update nl.rs (#11155) 2025-03-17 22:04:18 +08:00
solokot
2ef1dd99de Update ru.rs (#11132) 2025-03-16 15:11:18 +08:00
XLion
960d9a042f Update tw.rs (#11126) 2025-03-15 21:37:39 +08:00
bovirus
10457dfe45 Italian language update (#11123) 2025-03-14 17:14:05 +08:00
21pages
971d4e6976 ipc example for test (#11127)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-03-14 00:21:05 +08:00
Mr-Update
2dbff45588 Update de.rs (#11106)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-03-13 09:36:27 +08:00
flusheDData
2bdb621417 Update es.rs (#11111)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-03-13 09:36:00 +08:00
Alex Rijckaert
47b00054d2 Update nl.rs (#11102)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-03-13 09:35:21 +08:00
21pages
d1c8b331c5 Option allow-d3d-render and fix ios ci (#11107)
* option `allow-d3d-render`, default false

Add this option because it fails on some machines

Signed-off-by: 21pages <sunboeasy@gmail.com>

* only add nokhwa to windows and linux dependencies

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-03-13 09:34:13 +08:00
fufesou
1403c939db fix: msi, silent install, do not launch app (#11115)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-13 09:33:30 +08:00
rustdesk
f1d2073d43 revert back, because useless 2025-03-12 20:06:35 +08:00
rustdesk
f7c930e153 fix https://github.com/rustdesk/rustdesk/discussions/11104 2025-03-12 19:07:25 +08:00
bovirus
22005bac75 Italian language update (#11088) 2025-03-12 15:14:47 +08:00
XLion
8f7bb5a032 Update tw.rs (#11087) 2025-03-12 15:14:21 +08:00
21pages
e0fd698101 opt dropdown button of connection page (#11086)
* Use menu style of the peer card
* Add margin between connection button and dropdown button
* Use thinner icon

Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-03-11 16:29:02 +08:00
21pages
b2cc9eac23 set rgba_data.valid to false when open a new single display on the old session (#11078)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-03-10 21:12:13 +08:00
Naveenkumar
5f521c80a7 Create ta.rs (#11068)
* Create ta.rs

* Update lang.rs
2025-03-10 21:07:15 +08:00
21pages
f0f999dc27 view camera (#11040)
* view camera

Signed-off-by: 21pages <sunboeasy@gmail.com>

* `No cameras` prompt if no cameras available,  `peerGetSessionsCount` use
connType as parameter

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix, use video_service_name rather than display_idx as key in qos,etc

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Adwin White <adwinw01@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-03-10 21:06:53 +08:00
fufesou
df4a101316 fix: build macos, default feature (#11075)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-10 10:16:17 +08:00
rustdesk
bdc53f0190 improve lock, https://github.com/rustdesk/rustdesk/issues/11067 2025-03-10 11:10:09 +09:00
Madis Otenurm
cef4175961 Update et.rs (#11054)
* Update et.rs

* Update et.rs

Added more missing translations

* Update et.rs

* Update et.rs

* Update et.rs
2025-03-08 17:38:58 +08:00
asereze
4ff75412c3 Update sc.rs (#11046) 2025-03-07 21:24:45 +08:00
dependabot[bot]
f1329ca69e Git submodule: Bump libs/hbb_common from 7cf11f7 to 83419b6 (#11042)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `7cf11f7` to `83419b6`.
- [Commits](7cf11f7b77...83419b6549)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-07 09:21:40 +08:00
ANB5Dev
c95aaf563e fixed Dutch names for macOS permissions (#11020) 2025-03-06 09:49:44 +08:00
Mani Ka
11fed81c4d Update reference to flutter web UI (#11029)
- the src js web UI has moved to directory v1 in flutter/web/js as v2 is under development . The link in README.md is returning 404
2025-03-06 09:49:25 +08:00
zuiyu
561bc18f49 Fix unused import with dxgiformat (#11032) 2025-03-06 09:49:04 +08:00
Ivan Beà
6946b863f7 Update ca.rs (#11024)
Update catalan localization
2025-03-05 16:14:42 +08:00
Yuni
8f68861920 Update CONTRIBUTING-KR.md (#11016)
* Update CONTRIBUTING-KR.md

Signed-off-by: yeonhee7935 <yeonhee7935@naver.com>

* Update CONTRIBUTING-KR.md

Signed-off-by: yeonhee7935 <yeonhee7935@naver.com>

---------

Signed-off-by: yeonhee7935 <yeonhee7935@naver.com>
2025-03-04 17:37:54 +08:00
21pages
171d178b09 make errorText of DialogTextField selectable (#11013)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-03-04 14:11:18 +08:00
rustdesk
7305b6bd1c https://github.com/rustdesk/rustdesk/discussions/937#discussioncomment-12373814 try to support citrix session 2025-03-03 19:06:01 +08:00
marboroman
6600c8c648 Update ru.rs (#10984)
Changed to the translation which makes sense.
2025-03-03 14:55:32 +08:00
marboroman
32b77f8968 Update ru.rs (#10979)
Made long translation short to fit in user interface.
2025-03-03 14:55:21 +08:00
marboroman
0bda90f8fb Update ru.rs (#10978)
update wrong translation for privacy mode activation
2025-03-03 14:55:11 +08:00
Lynilia
2b68c46fdc French localization rework (#10966) 2025-03-02 18:04:22 +08:00
fufesou
bfbf00f18c fix: custom client, settings button (#10974)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-03-01 19:43:01 +08:00
asereze
e17ab74040 Sardinian translation (#10941)
* Sardinian translation

Sardinian ("Sardu", ISO 639-1 code: "sc") translation.

* Update lang.rs

* Corrected typo
2025-03-01 18:39:58 +08:00
fufesou
41cd375e3c fix: potential memleak (#10955)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-28 12:14:40 +08:00
fufesou
0d919157c9 Fix/win build (#10954)
* fix: win build

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: win, build

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-28 11:56:17 +08:00
fufesou
00293a9902 Feat/macos clipboard file (#10939)
* feat: macos, clipboard file

Signed-off-by: fufesou <linlong1266@gmail.com>

* Can't reuse file transfer

Signed-off-by: fufesou <linlong1266@gmail.com>

* handle paste task

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-28 00:46:46 +08:00
rustdesk
bc3a58f6f4 1.3.9 2025-02-26 18:00:31 +08:00
rustdesk
d8496aba0b refactor is_custom_client 2025-02-26 00:32:54 +08:00
rustdesk
280c12942f improve android build 2025-02-25 00:30:29 +08:00
Andrzej Rudnik
3a5b30a5e7 Update pl.rs (#10901) 2025-02-24 23:08:48 +08:00
dependabot[bot]
c46023bbde Git submodule: Bump libs/hbb_common from 16900b9 to 7cf11f7 (#10895)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `16900b9` to `7cf11f7`.
- [Commits](16900b9b06...7cf11f7b77)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 08:58:25 +08:00
Kleofass
93feedc212 Update lv.rs (#10883) 2025-02-23 13:39:43 +08:00
luzpaz
6f1a769741 fix: source typo in flutter/lib/common/widgets/address_book.dart (#10884)
Found via codespell
2025-02-23 09:01:52 +08:00
171 changed files with 12163 additions and 1787 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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

View File

@@ -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:

View File

@@ -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
View 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)에서 활동하고 있습니다.

View File

@@ -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)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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

View File

@@ -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)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。
## 截图
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

90
examples/ipc.rs Normal file
View 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;
}
}

View File

@@ -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

Binary file not shown.

View File

@@ -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;
}

View File

@@ -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,
),

View File

@@ -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(),
),
],

View File

@@ -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);
}

View File

@@ -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,
),
);
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;
});
},
);
}),
),
),
]),
),
],
),
),

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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());
});

View File

@@ -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'),
)
],
),
),
],

View 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;
}
}
}

View 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;
}
}

View 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,
),
));
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View 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);
}
}

View File

@@ -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) {

View File

@@ -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;
}
}
}

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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;

View 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);
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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
})
]);
}

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -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(()),

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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,

View 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.

View 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
}

View 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
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View 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();
}
}
}

View 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(())
}
}

View 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();
}
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk-portable-packer"
version = "1.3.8"
version = "1.3.9"
edition = "2021"
description = "RustDesk Remote Desktop"

View 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"

View 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()
}

View 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(())
}

View 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())
}

View 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(())
}

View 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(())
}

View 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));
}

View File

@@ -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"] }

View 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) {}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk
pkgver=1.3.8
pkgver=1.3.9
pkgrel=0
epoch=
pkgdesc=""

View File

@@ -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())

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -12,3 +12,5 @@ EXPORTS
SetPropertyFromConfig
AddRegSoftwareSASGeneration
RemoveAmyuniIdd
InstallPrinter
UninstallPrinter

View File

@@ -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>

View 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");
}
}

View File

@@ -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=&quot;&apos;Y&apos;&quot;" />
<!-- 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=&quot;&apos;Y&apos;&quot;) AND (NOT CC_CONNECTION_TYPE=&quot;outgoing&quot;)"/>
<!-- 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=&quot;&apos;Y&apos;&quot;) AND (NOT CC_CONNECTION_TYPE=&quot;outgoing&quot;)"/>
<!-- 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 &gt;= 603 AND PRINTER = 1 OR PRINTER = &quot;Y&quot; OR PRINTER = &quot;y&quot;" />
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT &gt;= 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 &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>

View File

@@ -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>

View File

@@ -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 = &quot;Y&quot; OR PRINTER = &quot;y&quot;">
<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 = &quot;Y&quot; OR PRINTER = &quot;y&quot;)">
<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 = &quot;Y&quot; OR CREATESTARTMENUSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = &quot;Y&quot; OR CREATEDESKTOPSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;)" />
</Fragment>
</Wix>

View File

@@ -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>

View File

@@ -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-->

View File

@@ -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>

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.3.8
Version: 1.3.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.3.8
Version: 1.3.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.3.8
Version: 1.3.9
Release: 0
Summary: RPM package
License: GPL-3.0

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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();`,

View File

@@ -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 {

View File

@@ -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