Compare commits

...

118 Commits

Author SHA1 Message Date
fufesou
998b75856d feat: Add relative mouse mode (#13928)
* feat: Add relative mouse mode

- Add "Relative Mouse Mode" toggle in desktop toolbar and bind to InputModel
- Implement relative mouse movement path: Flutter pointer deltas -> `type: move_relative` -> new `MOUSE_TYPE_MOVE_RELATIVE` in Rust
- In server input service, simulate relative movement via Enigo and keep latest cursor position in sync
- Track pointer-lock center in Flutter (local widget + screen coordinates) and re-center OS cursor after each relative move
- Update pointer-lock center on window move/resize/restore/maximize and when remote display geometry changes
- Hide local cursor when relative mouse mode is active (both Flutter cursor and OS cursor), restore on leave/disable
- On Windows, clip OS cursor to the window rect while in relative mode and release clip when leaving/turning off
- Implement platform helpers: `get_cursor_pos`, `set_cursor_pos`, `show_cursor`, `clip_cursor` (no-op clip/hide on Linux for now)
- Add keyboard shortcut Ctrl+Alt+Shift+M to toggle relative mode (enabled by default, works on all platforms)
- Remove `enable-relative-mouse-shortcut` config option - shortcut is now always available when keyboard permission is granted
- Handle window blur/focus/minimize events to properly release/restore cursor constraints
- Add MOUSE_TYPE_MASK constant and unit tests for mouse event constants

Note: Relative mouse mode state is NOT persisted to config (session-only).
Note: On Linux, show_cursor and clip_cursor are no-ops; cursor hiding is handled by Flutter side.

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

* feat(mouse): relative mouse mode, exit hint

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

* refact(relative mouse): shortcut

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-09 10:03:14 +08:00
21pages
3a9084006f Allow configuring remote control permissions for different users (#13974)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-09 00:21:28 +08:00
fufesou
4d3ccc62e8 fix(file transfer): perm on "access-mode" (#13971)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-07 16:08:15 +08:00
fufesou
8fe10d61ea fix(terminal): linux, macOS, win as the controlled (#13930)
1. `TERM` on linux terminal.
2. `htop` command not found on macOS.
3. `vim` and `claude code cli` hung up on windows.

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-07 16:07:14 +08:00
21pages
5a183490dc fix submodule repository (#13975)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-07 14:11:20 +08:00
21pages
9dd4fa8646 add options: disable-change-permanent-password, disable-change-id, disable-unlock-pin (#13929)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-07 13:51:02 +08:00
Yero~
a05b619563 Fix: Window positioning out of bounds on multi-monitors setup #13828 (#13903) 2026-01-07 13:50:26 +08:00
Alex Rijckaert
7f9506b476 Update Dutch (#13970) 2026-01-06 18:15:54 +08:00
21pages
f65952cf1c fix(desktop): wakelock issue with multiple tabs in same window (#13956)
Each desktop isolate now independently tracks wakelock state.
  WakelockPlus.disable() is only called when all tabs within the
  same isolate are closed/minimized.

  WakelockPlus ensures screen stays awake as long as any isolate
  has wakelock enabled.

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-05 22:16:35 +08:00
Re*Index. (ot_inc)
7ac03ffefc Update Japanese translations in ja.rs (#13952) 2026-01-03 12:36:25 +08:00
bilimiyorum
f6d6c3afb5 Turkish language support (#13941)
New string entry
2026-01-02 22:13:32 +08:00
Alex Rijckaert
419703d2ea Update dutch translation for 'Show terminal extra keys' (#13939) 2026-01-02 22:11:18 +08:00
21pages
9301edef06 remove gzip encoding in Legacy AB pushes (#13937)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-02 10:24:47 +08:00
21pages
7e3f0a607b fix: add Content-Length header for empty body POST requests (#13940)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-02 09:14:31 +08:00
dependabot[bot]
dec0e7c56d Git submodule: Bump libs/hbb_common from fa15710 to 12f2a47 (#13923)
Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `fa15710` to `12f2a47`.
- [Release notes](https://github.com/rustdesk/hbb_common/releases)
- [Commits](fa157108be...12f2a47770)

---
updated-dependencies:
- dependency-name: libs/hbb_common
  dependency-version: 12f2a47770af7521588ccaa67731806f15d0132d
  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-12-31 13:28:45 +08:00
Lynilia
0758e10ae2 Update fr.rs (#13921) 2025-12-31 13:28:16 +08:00
Mr-Update
19ae785fa2 Update de.rs (#13919) 2025-12-31 13:28:04 +08:00
Kratos
918ce865ca Update hu.rs (#13918)
Translate new string
2025-12-31 13:27:53 +08:00
solokot
d27a21feee Update ru.rs (#13917) 2025-12-31 13:27:40 +08:00
VenusGirl❤
d8932b69a3 Update Korean (#13916) 2025-12-31 13:27:28 +08:00
bovirus
5af580f44d Italian language update (#13913) 2025-12-31 13:27:16 +08:00
alonginwind
3384eda8b7 feat(terminal): add two-row floating keyboard buttons for common commands (mobile only) (#13876)
* feat(terminal): add two-row floating keyboard buttons for common commands (mobile only)

* Fix missing newline at end of pl.rs

Add missing newline at the end of the file.
2025-12-28 15:41:25 +08:00
fufesou
969ea28d06 feat(fs): delegate win --server file reading to CM (#13736)
- Route Windows server-to-client file reads through CM instead of the connection layer
- Add FS IPC commands (ReadFile, CancelRead, SendConfirmForRead, ReadAllFiles) and CM data messages
  (ReadJobInitResult, FileBlockFromCM, FileReadDone, FileReadError, FileDigestFromCM, AllFilesResult)
- Track pending read validations and read jobs to coordinate CM-driven file transfers and clean them up
  on completion, cancellation, and errors
- Enforce a configurable file-transfer-max-files limit for ReadAllFiles and add stronger file name/path
  validation on the CM side
- Improve Flutter file transfer UX and robustness:
  - Use explicit percent/percentText progress fields
  - Derive speed and cancel actions from the active job
  - Handle job errors via FileModel.handleJobError and complete pending recursive tasks on failure
  - Wrap recursive directory operations in try/catch and await sendRemoveEmptyDir when removing empty directories

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-28 15:39:35 +08:00
fufesou
5b2101e17d fix(terminal): macos, env TERM (#13901)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-26 15:28:35 +08:00
Andrzej Rudnik
ec2d7f0519 Update pl.rs (#13893) 2025-12-26 13:31:49 +08:00
fufesou
656ce93d6e refact: ci, free disk space(Ubuntu) (#13900)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-25 17:10:49 +08:00
RustDesk
b69e871f9a Revert "fix: set TERM env variable for terminal to fix Delete key not working…" (#13894)
This reverts commit bba57069a8.
2025-12-24 22:59:13 +08:00
lif
bba57069a8 fix: set TERM env variable for terminal to fix Delete key not working (#13747)
Set TERM=xterm-256color when spawning PTY shell to ensure proper
handling of control sequences. This fixes the issue where Delete/
Backspace keys were not working in terminal connections, particularly
from iPad to Linux.

Fixes #13621
2025-12-24 18:18:51 +08:00
fufesou
6a701f1420 fix: linux, home (#13879)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-23 15:43:31 +08:00
alonginwind
eba847e62e Fix Terminal top content overlapping with notch (SafeArea) (#13724) 2025-12-22 21:08:38 +08:00
fufesou
b80eb2dc6c refact: remote toolbar icon (#13865)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-22 17:10:53 +08:00
21pages
1f9689dc00 show login dialog when clicking note if not logged in (#13856)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-12-21 22:18:18 +08:00
YuZhiYuanDev
84eb75d5b6 ci: update macOS runner from unsupported macos-13 to macos-latest (#13855)
- Replace deprecated `macos-13` with `macos-latest` runner
- Ensure CI compatibility with supported macOS versions
- Maintain build stability and future-proof workflows
2025-12-20 21:21:14 +08:00
21pages
4f2aea65ab require login for note (#13775)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-12-20 16:51:25 +08:00
fufesou
d6463f95b9 refact: remote toolbar show/hide (#13843)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-19 20:45:22 +08:00
mehdi-song
3e0688ab63 Update fa.rs (#13818)
* Update fa.rs

:-)

* Update fa.rs
2025-12-17 22:32:16 +08:00
RustDesk
692e90f779 Update flutter-build.yml (#13817) 2025-12-16 10:33:50 +08:00
RustDesk
e4faedcb62 Update flutter-build.yml (#13815) 2025-12-15 19:51:48 +08:00
fufesou
a32d36a97b fix(sudo -E): Ubuntu 25.10, run_as_user (#13796)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-14 20:52:10 +08:00
RustDesk
da2c678fb3 Revert "Disable signing commands in flutter-build.yml (#13750)" (#13808)
This reverts commit 822b6d1baf.
2025-12-14 17:41:18 +08:00
Mahdi Rahimi
7bdfa121f3 Update Arabic translation in ar.rs (#13738) 2025-12-12 21:37:15 +08:00
fufesou
b9a1369c6f fix: custom client, contains RustDesk (#13783)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-11 21:17:42 +08:00
fufesou
0112b3387e fix(CI): macOS, nasm, use 2.16.x (#13781)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-11 20:16:06 +08:00
fufesou
de9d86621d fix: macos, clipboard, text-based items (#13778)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-11 15:39:18 +08:00
YuZhiYuanDev
735862d1fd Replace unsupported macos-13 with a new runner (#13767) 2025-12-10 17:05:52 +08:00
rustdesk
a0537759b1 fix vi 2025-12-10 00:31:13 +08:00
minh
a79776c1c4 Update Vietnamese translations for various terms (#13756) 2025-12-09 16:58:34 +08:00
RustDesk
822b6d1baf Disable signing commands in flutter-build.yml (#13750)
Comment out signing commands in the Flutter build workflow.
2025-12-09 00:07:11 +08:00
fufesou
0065085ba2 fix: win, peer shortcut, colon to underscore (#13740)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-08 16:34:05 +08:00
rustdesk
4f4da20fc0 revert: flatpak command line is_root 2025-12-05 17:26:06 +08:00
rustdesk
eb0174ea53 flatpak command line is_root 2025-12-05 17:15:29 +08:00
Vasyl Gello
20ce626654 Fix OpenSSL build with Android NDK clang on x86 (#13684)
Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>
2025-12-04 17:54:07 +08:00
Alex Rijckaert
a342941ec1 Update Dutch translations for input notes (#13713) 2025-12-04 00:27:05 +08:00
21pages
a78a803a22 fix is_public (#13701)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-12-02 14:54:56 +08:00
fufesou
23754630e8 fix build (#13686)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-12-01 19:41:55 +08:00
bilimiyorum
8e6e91eb4a Turkish language support (#13673)
Current
2025-11-30 19:52:40 +08:00
fufesou
9cfa551163 fix: msi, prevent black window (#13665)
For msi version, the black window is shown when creating desktop
shortcut for connection.

The exe version does not have this issue.

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-28 17:25:43 +08:00
rustdesk
5b21441898 webrtc 2025-11-28 10:45:48 +08:00
fufesou
4ed8696d1d fix: file transfer, jobs lost if conn is not established (#13635)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-26 19:15:32 +08:00
fufesou
ae06f27372 fix: sciter, cursor position mismatch (#13629)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-25 23:05:31 +08:00
XLion
33e1493932 Update tw.rs; Add space for cn.rs (#13609)
* Update tw.rs

* Update cn.rs

* Update tw.rs

* Update tw.rs
2025-11-25 01:08:48 +08:00
summoner
22b1dcaf7b Translation: Update hungarian hu.rs (#13578)
* Translation: Update hungarian hu.rs

Translate new strings
Fix translation

* Translation: update hu.rs

Fix translation

* Update hu.rs

Fix translation
2025-11-22 15:16:02 +08:00
fufesou
426a68775f feat: macos, update dmg (#13579) 2025-11-21 10:27:37 +08:00
RustDesk
3c0be4e40e Revert "feat: macos, update dmg (#13539)" (#13577)
This reverts commit a6571e71e4.
2025-11-20 23:18:00 +08:00
21pages
3787b45b49 fix python scripts read offset (#13574)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-20 22:15:42 +08:00
rustdesk
7d06de00fb 24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04
ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04
ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04 ubuntu24.04
ubuntu24.04 ubuntu24.04 ubuntu
2025-11-19 11:39:37 +08:00
fufesou
6f8af9d114 refact: flatpak, socket x11, better compatibility (#13551)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-19 11:03:06 +08:00
fufesou
0a672f092a fix: flatpak, wayland, cursor image (#13544)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-18 23:32:40 +08:00
alonginwind
ef62f1db29 Fix terminal clear command to remove residual output (#13531)
* Fix terminal clear command to remove residual output

* Update flutter/lib/models/terminal_model.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/desktop/pages/terminal_page.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix: Prevent "Build scheduled during frame" in terminal resize

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 23:31:54 +08:00
fufesou
7f804a0e45 refact: wayland, pipewire display offset cache to file (#13542)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-18 14:16:59 +08:00
fufesou
b2dff336ce fix: wayland controlled side, cursor misalignment (#13537)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-18 00:37:15 +08:00
fufesou
a6571e71e4 feat: macos, update dmg (#13539)
* feat: macos, update dmg

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

* Update src/platform/macos.rs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: macos update, remove temp update dir

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

* refact: macos update, print

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 00:30:23 +08:00
rustdesk
81f711eb00 update lock 2025-11-17 23:09:29 +08:00
rustdesk
c8a8e06558 update common 2025-11-17 19:09:50 +08:00
rustdesk
2c079f53a9 web client custom 2025-11-17 00:30:38 +08:00
Lynilia
322ffe288e Update fr.rs (#13533) 2025-11-16 23:47:25 +08:00
Mr-Update
c340eb0e57 Update de.rs (#13529) 2025-11-15 21:07:09 +08:00
rustdesk
4e953291ed fix ci android failure 2025-11-15 15:00:29 +08:00
solokot
1dea5fee0e Update ru.rs (#13518) 2025-11-14 17:02:33 +08:00
VenusGirl❤
9f24b46fee Update Korean (#13516) 2025-11-14 17:02:21 +08:00
bovirus
0808c41a1c Italian language update (#13513) 2025-11-14 17:02:09 +08:00
21pages
296c6df462 ask for note at end of connection (#13499)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-13 23:35:40 +08:00
Dzung Do
13ee3e907d Update Vietnamese translations for various terms (#13490) 2025-11-12 16:26:17 +08:00
Jonathan Gilbert
ce7d794b4c Fix config sync reconnection retry loop (#13487)
* Updated the server connection retry loop when syncing config changes in src/server.rs to not break after reconnecting.

* Update server.rs

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-11-12 00:27:58 +08:00
Alireza Shahamiri
fb10069632 fix(fa.rs): Fixes persian translation typo (#13459) 2025-11-11 14:45:38 +08:00
21pages
43a7677644 add user_group.py, device_group.py, update users.py (#13453)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-11 14:45:04 +08:00
fufesou
58fa32d7ea fix: sciter ui (#13474)
Element has no method - is_outgoing_only.

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-10 22:30:20 +08:00
fufesou
934d6c3987 refact: rust backtrace logs (#13467)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-10 15:43:46 +08:00
rustdesk
2d7c6ef21f log crash traceback, copilot 2025-11-10 10:01:15 +08:00
Alex Rijckaert
99a97e6a6c Update Dutch translations in nl.rs (#13457) 2025-11-09 21:30:48 +08:00
rustdesk
017a10e8c8 1.4.4 2025-11-07 15:16:59 +08:00
Mr-Update
41ffa8ba08 Update de.rs (#13448) 2025-11-07 15:15:38 +08:00
21pages
e029d00cfa edge scroll thickness adjustment (#13445)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-07 01:15:13 +08:00
Lynilia
268534d5e7 Update fr.rs (#13438) 2025-11-06 17:13:27 +08:00
fufesou
a7d2bc63f9 fix: sciter, advanced options, UI (#13429)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-06 17:13:11 +08:00
bovirus
559115c43c Italian language update (#13414)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-11-05 17:53:35 +08:00
Greg
1277c7d60c See https://github.com/rustdesk/rustdesk/discussions/13350 (#13427)
This adds DBUS_SESSION_BUS_ADDRESS to the collection of "pilfered environment" variables on Linux.

The net effect should be that Wayland sub processes launched by rustdesk --service (--server and --tray) get the right bus.

Presumably this happens with by systemd environment management, but on Void Linux & other non-systemd, this prevents a connection to a client from any controller with a message about service not available. (As the DBUS lookup fails).

On X11, this is not an issue as the retrieval of Wayland capabilities via DBUS registry is not required.

In general, this is a more robust Wayland solution than just grabbing WAYLAND_DISPLAY, since WAYLAND is heavily dependent on DBUS for protocol registration.

Co-authored-by: Greg Ke <Greg-repo-sync@akua.com>
2025-11-05 10:29:37 +08:00
fufesou
9b69c7e972 refact: show proxy settings on ios (#13423)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-04 17:55:04 +08:00
Jonathan Gilbert
a903f710ea Eliminate build warnings from the Scrap crate (#13383)
* Updated build.rs to tell RustC that dxgi, quartz and x11 are expected configurations.
Added lifetime annotations to various methods in common/aom.rs and common/vpxcodec.rs.
Updated common/vpx.rs to allow unused_imports in the generated bindings.
Updated dxgi/mag.rs to allow non_snake_case identifiers like "dwFilterMode".

* Added lifetime annotations to methods in common/hwcodec.rs and common/vram.rs.

* Switched syntax for the rustc-check-cfg directive emitted by build.rs in the scrap crate to use syntax compatible with Rust toolchain version 1.75. The double-colon syntax requires 1.77 or newer, but the older single-colon syntax works fine on newer versions for this directive.

* Update libs/scrap/build.rs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert apparently-erroneous AI suggestion. It's usually pretty good, but not always right it seems. :-)

This reverts commit bf862b13f6.

* Removed redundant configuration directives from libs/scrap/build.rs.

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-04 10:19:13 +08:00
21pages
b75f4daa47 flutter: keep chat window within screen bounds to prevent hidden chat window (fixes rustdesk#13397) (#13406) 2025-11-04 09:44:13 +08:00
fufesou
fef44ffa57 refact: translate tip id (#13412)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-04 08:56:43 +08:00
fufesou
5a812e3b2f fix: ui issues (#13381)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-03 23:23:08 +08:00
fufesou
910dcf2036 refact: tls, native-tls fallback rustls-tls (#13263)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-11-03 23:21:01 +08:00
21pages
44a28aa5bd update hwcodec, support H265 encoding on Intel chip Macs (#13411)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-03 22:55:03 +08:00
21pages
f7f947beb9 restrict CLI options when settings disabled (#13400)
Prevent --password, --set-id, and --option command line arguments
from modifying settings when is_disable_settings() returns true.

Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-11-03 09:43:27 +08:00
RustDesk
d03a9e2baf Fix macos bigsur cvbuffer crash (#13392)
* Fix macOS Big Sur crash with CVBufferCopyAttachments

Add FFmpeg patch to use weak_import for CVBufferCopyAttachments API
to prevent dyld crash on macOS Big Sur (11.x).

The CVBufferCopyAttachments function is only available on macOS 12+.
Even though FFmpeg has a runtime check with __builtin_available, the
symbol is still resolved at load time, causing immediate crash on older
macOS versions.

With weak_import attribute, the function pointer will be NULL on
macOS < 12, allowing the code to safely fall back to the deprecated
CVBufferGetAttachments API.

Fixes: #13377

* update common
2025-11-02 22:08:03 +08:00
Mr-Update
ca22316e95 Update de.rs (#13375) 2025-11-02 21:20:56 +08:00
solokot
ef99c479aa Update ru.rs (#13367) 2025-11-02 21:20:42 +08:00
Jonathan Gilbert
fa9260c763 Made the import of hbb_common::sysinfo::System more precisely conditioned in src/platform/mod.rs. (#13388) 2025-11-02 21:19:44 +08:00
Jonathan Gilbert
fab11c8ffa Allow non_snake_case identifiers in src/setup/mod.rs for libs/remote_printer. (#13384) 2025-11-02 21:19:13 +08:00
VenusGirl❤
9bd9658a92 Update Korean (#13359)
Update Korean
2025-11-01 14:40:28 +08:00
bovirus
213880c14d Italian language update (#13358) 2025-11-01 14:40:17 +08:00
Alessandro De Blasis
0550397046 fix: scale custom on mobile (#13324)
* fix: prevent custom scale dialog from closing when interacting with slider

Wrapped MobileCustomScaleControls in GestureDetector with opaque behavior
to prevent touch events from propagating to parent dialog's clickMaskDismiss
handler. The slider now works correctly without closing the dialog.

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>

* Update flutter/lib/mobile/widgets/custom_scale_widget.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/mobile/widgets/custom_scale_widget.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/mobile/widgets/custom_scale_widget.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/mobile/widgets/custom_scale_widget.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert "fix: mobile remove "Scale custom" (#13323)"

This reverts commit 265d08fc3b.

* chore: keep remote_toolbar.dart cleanup (remove dead code)

  The dead code removed in 265d08fc3 hasn't been used since Aug 2023.
  Only reverting toolbar.dart is needed for the mobile Scale custom fix.

* Update flutter/lib/mobile/pages/remote_page.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: Implement CustomScaleControlsMixin for shared scaling logic across mobile and desktop widgets

- Introduced a new mixin `CustomScaleControlsMixin` to encapsulate custom scale control logic, allowing for code reuse in both mobile and desktop widgets.
- Refactored `_CustomScaleMenuControlsState` and `_MobileCustomScaleControlsState` to utilize the new mixin, simplifying the scaling logic and reducing code duplication.
- Updated slider handling and state management to leverage the mixin's methods for improved maintainability.

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>

* Update flutter/lib/desktop/widgets/remote_toolbar.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/mobile/widgets/custom_scale_widget.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update flutter/lib/mobile/pages/remote_page.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: changed from mixin to abstract class

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>

* Revert "Update flutter/lib/mobile/pages/remote_page.dart"

This reverts commit 7c35897408.

* refactor: remove unnecessary tap event handling in custom scale controls

- Removed the `onTap` handler from the

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>

* refactor: simplify MobileCustomScaleControls usage in remote_page.dart

- Removed unnecessary GestureDetector wrapper around MobileCustomScaleControls for cleaner code.

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>

---------

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 11:08:03 +08:00
21pages
f7a5a506f6 rename RustDeskApplication to MainApplication (#13362)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-31 11:07:32 +08:00
21pages
0f34c50bd2 fix reqwest proxy auth (#13354)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-30 20:01:17 +08:00
Jonathan Gilbert
055826e26f Edge scrolling (#13247)
* Repurposed the MacOS-specific platform channel mechanism for all platforms:
- Renamed the channel from "org.rustdesk.rustdesk/macos" to "org.rustdesk.rustdesk/host".
- Renamed _osxMethodChannel in platform_channel.dart to _hostMethodChannel.
- Updated linux/my_application.cc to use the fl_* API to set up a Method Channel and to dispose it during my_application_dispose.
- Updated windows/runner/flutter_window.cpp to use the C++ API to set up a Method Channel.
- Updated the channel name in macos/Runner/MainFlutterWindow.swift.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Added a method "bumpMouse" to the Platform Channel.
Added a thunk to call the method through the channel to platform_channel.dart.
Added implementation bump_mouse() in linux/my_application.cc using Gdk API calls. Updated host_channel_call_handler to process "bumpMouse" method call messages by calling bump_mouse.
Added implementation Win32Desktop::BumpMouse in windows/runner/win32_desktop.cpp/.h.  Updated the inline method call handler in flutter_window.cpp to handle "bumpMouse" method calls by calling Win32Desktop::BumpMouse.
Updated the method call handler in macos/Runner/MainFlutterWindow.swift to handle "bumpMouse" method call messages. Updated MainFlutterWindow to use a subclass of FlutterViewController exposing access to mouseLocationOutsideOfEventStream.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Added message type kWindowBumpMouse to the multiwindow window event model:
- Added constant kWindowBumpMouse to consts.dart.
- Updated the method handler attached to rustDeskWinManager by DesktopHomePageState to recognize kWindowBumpMouse and translate it to a call to RdPlatformChannel.bumpMouse.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Centralized serialization of ScrollStyle values, moving JSON and string conversions into methods toString/fromString and toJson/fromJson within the type.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Added new scroll style for edge scrolling:
- Added ScrollStyle enum member "scrolledge". Added corresponding constant kRemoteScrollStyleEdge to consts.dart for the string serialized form.
- Updated sites checking specifically for ScrollStyle.scrollbar to instead check for NOT ScrollStyle.scrollauto.
- Added radio buttons for the new "ScrollEdge" style to desktop_setting_page.dart and remote_toolbar.dart. Added new string "ScrollEdge" to lang/template.rs.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Implemented edge scrolling:
- Added methods edgeScrollMouse and pushScrollPositionToUI to class CanvasModel in model.dart.
- Added boolean parameter edgeScroll to handleMouse, handlePointerDevicePos and processEventToPeer in input_model.dart.
- Updated handlePointerDevicePos in input_model.dart to call edgeScrollMouse on move events when the edgeScroll parameter is true.
- Added convenience accessor useEdgeScroll to the InputModel class. Updated call sites to handleMouse to use it to supply the value for the edgeScroll parameter.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Updated CanvasModel.edgeScrollMouse to be resilient to receiving events when _horizontal/_vertical aren't wired up to any UI.

* Updated CanvasModel to take notifications of resizes via method notifyResize and to suppress edge scrolling briefly after a resize.
Updated the onWindowResized handler in tabbar_widget.dart to call notifyResize on the canvasModel of any RemotePage tabs.

* Half a go at fixing MainFlutterWindow.swift.

* Copilot feedback.

* Applied fix suggested by Copilot in its explanation of the build error.

* Fixed a couple of silly errors in windows/runner/flutter_window.cpp.

* Fixed MainFlutterWindow.swift build errors.

Co-Authored-By: fufesou <linlong1266@gmail.com>
Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Moved new translation to the end of template.rs.
Reran res/lang.py.

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>

* Switched MainFlutterWindow.swift to use NSEvent.mouseLocation.

* Updated MainFlutterWindow.swift code based on build error.

* Fixed silly typo.

* Reintroduced the coordinate system translation in MainFlutterWindow.swift.

* Updated edgeScrollMouse in model.dart to add a "safe zone" around the window frame that doesn't trigger edge scrolling.

* Updated the bumpMouse handler in MainFlutterWindow.swift to call CGAssociateMouseAndMouseCursorPosition to cancel event suppression.

* Added debug annotation to the onWindowResized event in tabbar_widget.dart.

* Fix parameter type for CGAssociateMouseAndMouseCursorPosition in MainFlutterWindow.swift.

* tabbar_widget.dart: onWindowResized -> onWindowResize

* Removed temporary diagnostic debugPrint from tabbar_widget.dart.

* Updated MainFlutterWindow.swift to obtain the mouse position by creating a dummy CGEvent. The old NSEvent.mouseLocation code is left as a fallback.

* The documentation said to be sure to call CFRelease, but apparently it's a build error to do so. :-P

* Replaced CGEvent calls in MainFlutterWindow.swift with uses of the CGEvent wrapper struct.

* Added argument label to call to CGEvent.init.

* Changed mouseLoc from piecewise assignment to assignment of the whole structure, as it is not yet initialized at that point.

* Linux platform channel: Refactored bump_mouse, setting the stage for a future Wayland implementation.
- Made a new top-level bump_mouse method in bump_mouse.cc/.h.
- Moved the X11-specific implementation to bump_mouse_x11 in bump_mouse_x11.cc/h.
Reworked the bumpMouse operation to have a boolean return value:
- Updated bumpMouse in platform_channel.dart to return a Future<bool> instead of a Future<void>.
- Windows platform channel: Updated BumpMouse in win32_desktop.cpp to return a bool value. Updated the method call handler "bumpMouse" branch in flutter_window.cpp to propagate the BumpMouse return value back to the originating MethodCall.
- MacOS platform channel: Updated the "bumpMouse" branch in the method call handler in MainFlutterWindow.swift to pass true or false into the 'result()' call.
- Linux platform channel: Updated the bump_mouse top-level method and its underlying implementation bump_mouse_x11 to return bool values. Updated the "bumpMouse" branch of host_channel_call_handler in my_application.cc to propagate the result value back up the method channel.
- Updated the kWindowBumpMouse branch of the method handler registered in desktop_home_page.dart to propagate a return value from RdplatformChannel.bumpMouse.

* Reworked the edge scrolling computations in model.dart to use Vector2 from the vector_math package. Updated pubspec.yaml to declare a dependency on vector_math.

* Added an alternative edge scrolling mechanism for when "Bump Mouse" functionality is unavailable:
- Added methods setEdgeScrollTimer and cancelEdgeScrollTimer to model.dart, along with a few state fields.
- Updated edgeScrollMouse to latch the (x, y) coordinate of the last edge scroll event, in case it will be autorepeating.
- Updated edgeScrollMouse to check whether the call to the kWindowBumpMouse method of rustDeskWinManager (and thus the underlying bump_mouse method) succeeded, and to switch to timer-based autorepeat if it fails. Made edgeScrollMouse async to allow awaiting the result of the kWindowBumpMouse method call.
- Updated input_model.dart to call cancelEdgeScrollTimer when a new move event is being processed.
- Updated remote_page.dart to call cancelEdgeScrollTimer when the pointer exits the area represented by the view.

* Fixed scroll percentage math in edgeScrollMouse in model.dart.

* Fixed declared return value for Win32Desktop::BumpMouse in win32_desktop.h.

* Fixed vector_math dependency version in pubspec.yaml to be compatible with the codebase standard Flutter version.

* Added class EdgeScrollFallbackState to model.dart for tracking the state of the edge scroll fallback strategy. Factored out the actual edge scrolling action from CanvasModel.edgeScrollMouse to new method performEdgeScroll so that EdgeScrollFallbackState can call it. Updated edgeScrollMouse to not call performEdgeScroll when it's enabling the fallback strategy.
Updated CanvasModel to use EdgeScrollFallbackState instead of directly tracking the state. Removed method setEdgeScrollTimer.
Added method initializeEdgeScrollFallback to CanvasModel that takes a TickerProvider. Updated _RemotePageState to include the mixin TickerProviderStateMixin. Updated _RemotePageState.initState to call canvasModel.initializeEdgeScrollFallback.
Updated handlePointerDevicePos in input_model.dart to not call cancelEdgeScrollTimer before edgeScrollMouse.
Renamed CanvasModel.cancelEdgeScrollTimer to CanvasModel.cancelEdgeScroll.
Updated the calculations in CanvasModel.edgeScrollMouse to only factor in the safe zone if BumpMouse is working. (Otherwise the problem with resizing can't possibly occur.)

* Updated CanvasModel.edgeScrollMouse in model.dart to handle the situation where only one of the scrollbars is active. Factored extraction of scrollbar data into new function getScrollInfo.

* Updated onWindowResize in tabbar_widget.dart to be resilient to RemotePage instances that don't yet have an ffi reference. Added property hasFFI to remote_page.dart.

* Removed debug output from model.dart.

* PR feedback:
- Added filtering to diagnostic output in the method handler in desktop_home_page.dart to exclude the very chatty kWindowBumpMouse-related output.
- Removed the diagnostic output from bumpMouse in platform_channel.dart for the same reason.
- Updated setScrollPercent to coalesce NaN values for x and y to 0.
- Initialized the GError pointer variable passed into fl_method_call_respond_success in linux/my_application.cc to NULL.
- Added bounds checking of the argument values in the EncodableList branch of the "bumpMouse" method call handler in windows/runner/flutter_window.cpp.

* Added a latch mechanism that keeps edge scrolling disabled until the cursor is observed to be in the inner area bounded by the edge scroll areas:
- Added tristate enumerated type EdgeScrollState to model.dart. In addition to inactive and active states, there is state armed which behaves like inactive but can transition to active when conditions are met.
- Added a field to CanvasModel of type EdgeScrollState. Added methods disableEdgeScroll and rearmEdgeScroll.
- Updated enterView to call canvasModel.rearmEdgeScroll and leaveView to call canvasModel.disableEdgeScroll in remote_page.dart.
- Updated edgeScrollMouse to check the state, disabling edge scrolling when the state is not active and transitioning from armed to active when the mouse is in the interior space.
- Removed the notifyResize/_suppressEdgeScroll mechanism from CanvasModel in model.dart as it is no longer necessary.
- Removed the "safe zone" mechanism from CanvasModel.edgeScrollMouse in model.dart as it is no longer necessary.
- Switched the onWindowResize handler in DesktopTabState in tabbar_widget.dart back to onWindowResized, now that it is no longer delivering canvasModel.notifyResize to all RemotePage tabs.

* Fixed memory leak: Added call to free GError object returned by Flutter API in the event of an error.

* PR feedback:
- Copilot: Use type annotations.
- Copilot: Condition to stop edge scrolling when fallback strategy is in use and the mouse is moved back to the centre.
- Copilot: Check FLValue type before calling fl_value_get_int.
- Copilot: Support list-style method channel dispatch in "bumpMouse" handler for macos as the linux and windows implementations already do.
- Naming convention for constants.
- Left-over variable from previous strategy: _suppressEdgeScroll.
- Unnecessary extra parentheses in edge scroll area conditions.

* Removed property suppressEdgeScroll referencing now-removed field _suppressEdgeScroll in model.dart.
Removed accidental extra blank line in MainFlutterWindow.swift.

* Switched CanvasModel.setScrollPercent to use double.isFinite instead of double.isNaN to test for proper numerical values.

* PR feedback:
- Copilot: Use Vector2.length2 instead of Vector2.length to avoid an unnecessary sqrt in comparison with zero.
- Copilot: Baleet unnecessary semicolons from Swift code.

* PR feedback:
- Copilot: Check argList.count before indexing it

* Oops with the semicolons again.

* Edge scroll, active local cursor

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

* Remove duplicated condition checks

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

* Chore

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

* PR feedback:
- Copilot: Removed unused property hasFFI from remote_page.dart.
- Copilot: Updated updateScrollStyle in model.dart to be resilient to the possibility of bind.sessionGetScrollStyle returning null.

* Factored local cursor updates out of CanvasModel.moveDesktopMouse in model.dart, adding new methods activateLocalCursor and updateLocalCursor.
Updated handlePointerDevicePos in input_model.dart to call canvasModel.updateLocalCursor on every mouse event.
Updated initState in remote_page.dart to schedule a call to canvasModel.activateLocalCursor as a first-image callback.

* Updated the explanation for rounding away from 0 in edgeScrollMouse in model.dart.

---------

Signed-off-by: Jonathan Gilbert <logic@deltaq.org>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2025-10-30 19:54:11 +08:00
flusheDData
a30582c840 New Spanish transtion terms (#13344)
* Update es.rs

New terms added

* Update es.rs

New terms added
2025-10-30 15:34:56 +08:00
21pages
d106d97b99 mobile verify both webpki and installed CA (#13272)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-30 13:59:00 +08:00
196 changed files with 15299 additions and 2310 deletions

View File

@@ -81,9 +81,23 @@ jobs:
# - { target: x86_64-apple-darwin , os: macos-10.15 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
- { target: x86_64-unknown-linux-gnu , os: ubuntu-22.04 }
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
steps:
- name: Free Disk Space (Ubuntu)
if: runner.os == 'Linux'
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
# But pinning to a specific version to avoid unexpected issues is preferred.
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
with:

View File

@@ -39,13 +39,13 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.3"
VERSION: "1.4.5"
NDK_VERSION: "r27c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
jobs:
generate-bridge:
@@ -238,7 +238,7 @@ jobs:
shell: bash
run: |
pip3 install requests argparse
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
- name: Build self-extracted executable
shell: bash
@@ -269,7 +269,7 @@ jobs:
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
shell: bash
run: |
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
- name: Publish Release
uses: softprops/action-gh-release@v1
@@ -404,7 +404,7 @@ jobs:
shell: bash
run: |
pip3 install requests argparse
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
- name: Build self-extracted executable
shell: bash
@@ -421,7 +421,7 @@ jobs:
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
shell: bash
run: |
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
- name: Publish Release
uses: softprops/action-gh-release@v1
@@ -444,7 +444,7 @@ jobs:
- {
arch: aarch64,
target: aarch64-apple-ios,
os: macos-13,
os: macos-latest,
vcpkg-triplet: arm64-ios,
}
steps:
@@ -562,7 +562,7 @@ jobs:
job:
- {
target: x86_64-apple-darwin,
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
vcpkg-triplet: x64-osx,
@@ -623,7 +623,7 @@ jobs:
- name: Install build runtime
run: |
brew install llvm create-dmg nasm
brew install llvm create-dmg
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
if command -v pkg-config &>/dev/null; then
echo "pkg-config is already installed"
@@ -631,6 +631,17 @@ jobs:
brew install pkg-config
fi
- name: Install NASM
run: |
# Install NASM 2.16.x from official release.
# Do NOT use `brew install nasm` which installs NASM 3.x.
# NASM 3.x is a complete rewrite with incompatible CLI options and removed features.
# aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly.
wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip
unzip nasm-2.16.03-macosx.zip
sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm
nasm --version
- name: Install flutter
uses: subosito/flutter-action@v2
with:
@@ -1001,6 +1012,8 @@ jobs:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: |
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
# Increase Gradle JVM memory for CI builds
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
# temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in
@@ -1208,6 +1221,8 @@ jobs:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: |
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
# Increase Gradle JVM memory for CI builds
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
# temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
mv ./flutter/android/app/src/main/jniLibs/arm64-v8a/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
@@ -1443,7 +1458,8 @@ jobs:
rpm \
unzip \
wget \
xz-utils
xz-utils \
libssl-dev
# we have libopus compiled by us.
apt-get remove -y libopus-dev || true
# output devs
@@ -1723,12 +1739,13 @@ jobs:
unzip \
wget \
xz-utils \
zip
zip \
libssl-dev
# arm-linux needs CMake and vcokg built from source as there
# are no prebuilts available from Kitware and Microsoft
if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then
# install gcc/g++ 8 for vcpkg and OpenSSL headers for CMake
apt-get install -y gcc-8 g++-8 libssl-dev
apt-get install -y gcc-8 g++-8
# bootstrap CMake amd add it to PATH
git clone --depth 1 https://github.com/kitware/cmake -b "v${{ env.SCITER_ARMV7_CMAKE_VERSION }}" /tmp/cmake
pushd /tmp/cmake

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.3"
VERSION: "1.4.5"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -10,6 +10,6 @@ jobs:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: RustDesk.RustDesk
version: "1.4.3"
release-tag: "1.4.3"
version: "1.4.5"
release-tag: "1.4.5"
token: ${{ secrets.WINGET_TOKEN }}

1459
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.3"
version = "1.4.5"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -83,6 +83,8 @@ shutdown_hooks = "0.1"
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
stunclient = "0.4"
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
[target.'cfg(not(target_os = "linux"))'.dependencies]
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
@@ -121,10 +123,19 @@ winapi = { version = "0.3", features = [
] }
windows = { version = "0.61", features = [
"Win32",
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System",
"Win32_System_Diagnostics",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Environment",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_UI_Shell",
] }
winreg = "0.11"
windows-service = "0.6"
@@ -165,13 +176,6 @@ fontdb = "0.23"
bytemuck = "1.23"
ttf-parser = "0.25"
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "native-tls", "gzip"], default-features=false }
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
[target.'cfg(target_os = "linux")'.dependencies]
psimple = { package = "libpulse-simple-binding", version = "2.27" }
pulse = { package = "libpulse-binding", version = "2.27" }
@@ -181,7 +185,6 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
dbus = "0.9"
dbus-crossroads = "0.5"
pam = { git="https://github.com/rustdesk-org/pam" }
users = { version = "0.11" }
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
percent-encoding = {version = "2.3", optional = true}
@@ -192,6 +195,9 @@ termios = "0.3"
terminfo = "0.8"
winit = "0.30"
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.13"
jni = "0.21"

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.3
version: 1.4.5
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.3
version: 1.4.5
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ fn build_mac() {
b.flag("-DNO_InputMonitoringAuthStatus=1");
}
}
b.file(file).compile("macos");
b.flag("-std=c++17").file(file).compile("macos");
println!("cargo:rerun-if-changed={}", file);
}

View File

@@ -55,8 +55,7 @@
],
"finish-args": [
"--share=ipc",
"--socket=fallback-x11",
"--socket=wayland",
"--socket=x11",
"--share=network",
"--filesystem=home",
"--device=dri",

View File

@@ -1,4 +1,6 @@
import com.google.protobuf.gradle.*
import groovy.json.JsonSlurper
plugins {
id "com.google.protobuf" version "0.9.4"
id "com.android.application"
@@ -30,8 +32,37 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
dependencies {
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
// Add rustls-platform-verifier Android support
String findRustlsPlatformVerifierMavenDir() {
def dependencyText = providers.exec {
it.workingDir = new File("../..")
commandLine("cargo", "metadata", "--format-version", "1")
}.standardOutput.asText.get()
def dependencyJson = new JsonSlurper().parseText(dependencyText)
def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" }
if (pkg == null) {
throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!")
}
def manifestPath = file(pkg.manifest_path)
def mavenDir = new File(manifestPath.parentFile, "maven")
if (!mavenDir.exists()) {
throw new GradleException("Maven directory not found at: ${mavenDir.path}")
}
println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}")
return mavenDir.path
}
repositories {
maven {
url = findRustlsPlatformVerifierMavenDir()
metadataSources.artifact()
}
}
protobuf {
@@ -67,7 +98,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.carriez.flutter_hbb"
minSdkVersion 21
minSdkVersion 22
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -97,8 +128,10 @@ flutter {
}
dependencies {
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5'
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
implementation 'com.caverock:androidsvg-aar:1.4'
implementation "rustls:rustls-platform-verifier:0.1.1"
}

View File

@@ -1,4 +1,7 @@
# Keep class members from protobuf generated code.
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
<fields>;
}
}
# Keep rustls-platform-verifier classes for JNI
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }

View File

@@ -23,6 +23,7 @@
</queries>
<application
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:label="RustDesk"
android:requestLegacyExternalStorage="true"

View File

@@ -0,0 +1,17 @@
package com.carriez.flutter_hbb
import android.app.Application
import android.util.Log
import ffi.FFI
class MainApplication : Application() {
companion object {
private const val TAG = "MainApplication"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "App start")
FFI.onAppStart(applicationContext)
}
}

View File

@@ -13,6 +13,7 @@ object FFI {
}
external fun init(ctx: Context)
external fun onAppStart(ctx: Context)
external fun setClipboardManager(clipboardManager: RdClipboardManager)
external fun startServer(app_dir: String, custom_client_config: String)
external fun startService()

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" style="isolation:isolate" viewBox="541.937 521.772 32 32"><path fill="none" d="M541.937 521.772h32v32h-32v-32Z"/><path fill-rule="evenodd" d="M552.145 539.981h11.584c.446 0 .808.362.808.808v.536c0 .786-.639 1.425-1.425 1.425h-10.35a1.426 1.426 0 0 1-1.425-1.425v-.536c0-.446.362-.808.808-.808Zm-1.761-3.511h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.972.972 0 0 1-.972-.971v-.899c0-.536.436-.971.972-.971Zm3.551 0h.9c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.9a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .972.435.972.971v.899a.972.972 0 0 1-.972.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm-14.383-3.512h1.25c.44 0 .796.357.796.796v1.25a.796.796 0 0 1-.796.796h-1.25a.796.796 0 0 1-.795-.796v-1.25c0-.439.356-.796.795-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm-9.553-3.85h13.252c1.407 0 2.755.507 3.748 1.409.993.902 1.552 2.127 1.552 3.404v7.702c0 1.277-.559 2.501-1.552 3.403-.993.902-2.341 1.409-3.748 1.409h-13.252c-1.407 0-2.755-.507-3.748-1.409-.993-.902-1.552-2.126-1.552-3.403v-7.702c0-1.277.559-2.502 1.552-3.404.993-.902 2.341-1.409 3.748-1.409Zm13.105 3.85h1.25c.439 0 .795.357.795.796v1.25a.796.796 0 0 1-.795.796h-1.25a.796.796 0 0 1-.796-.796v-1.25c0-.439.356-.796.796-.796Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -24,6 +24,7 @@ import 'package:provider/provider.dart';
import 'package:uni_links/uni_links.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window_size/window_size.dart' as window_size;
@@ -44,7 +45,7 @@ import 'package:flutter_hbb/native/win32.dart'
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
import 'package:flutter_hbb/native/common.dart'
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_hbb/utils/http_service.dart' as http;
final globalKey = GlobalKey<NavigatorState>();
final navigationBarKey = GlobalKey();
@@ -1010,13 +1011,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
});
}
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
void showToast(String text,
{Duration timeout = const Duration(seconds: 3),
Alignment alignment = const Alignment(0.0, 0.8)}) {
final overlayState = globalKey.currentState?.overlay;
if (overlayState == null) return;
final entry = OverlayEntry(builder: (context) {
return IgnorePointer(
child: Align(
alignment: const Alignment(0.0, 0.8),
alignment: alignment,
child: Container(
decoration: BoxDecoration(
color: MyTheme.color(context).toastBg,
@@ -1681,13 +1684,12 @@ class LastWindowPosition {
this.offsetHeight, this.isMaximized, this.isFullscreen);
bool equals(LastWindowPosition other) {
return (
(width == other.width) &&
(height == other.height) &&
(offsetWidth == other.offsetWidth) &&
(offsetHeight == other.offsetHeight) &&
(isMaximized == other.isMaximized) &&
(isFullscreen == other.isFullscreen));
return ((width == other.width) &&
(height == other.height) &&
(offsetWidth == other.offsetWidth) &&
(offsetHeight == other.offsetHeight) &&
(isMaximized == other.isMaximized) &&
(isFullscreen == other.isFullscreen));
}
Map<String, dynamic> toJson() {
@@ -1815,7 +1817,8 @@ Future<void> saveWindowPosition(WindowType type,
final WindowKey key = (type: type, windowId: windowId);
final bool haveNewWindowPosition = (_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
final bool haveNewWindowPosition =
(_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning;
if (haveNewWindowPosition || isPreviousNewWindowPositionPending) {
@@ -1841,10 +1844,11 @@ Future<void> _saveWindowPositionActual(WindowKey key) async {
await bind.setLocalFlutterOption(
k: windowFramePrefix + key.type.name, v: pos.toString());
if ((key.type == WindowType.RemoteDesktop || key.type == WindowType.ViewCamera) &&
if ((key.type == WindowType.RemoteDesktop ||
key.type == WindowType.ViewCamera) &&
key.windowId != null) {
await _saveSessionWindowPosition(
key.type, key.windowId!, pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
await _saveSessionWindowPosition(key.type, key.windowId!,
pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
}
}
}
@@ -1931,44 +1935,41 @@ Future<Offset?> _adjustRestoreMainWindowOffset(
return null;
}
double? frameLeft;
double? frameTop;
double? frameRight;
double? frameBottom;
if (isDesktop || isWebDesktop) {
for (final screen in await window_size.getScreenList()) {
frameLeft = frameLeft == null
? screen.visibleFrame.left
: min(screen.visibleFrame.left, frameLeft);
frameTop = frameTop == null
? screen.visibleFrame.top
: min(screen.visibleFrame.top, frameTop);
frameRight = frameRight == null
? screen.visibleFrame.right
: max(screen.visibleFrame.right, frameRight);
frameBottom = frameBottom == null
? screen.visibleFrame.bottom
: max(screen.visibleFrame.bottom, frameBottom);
final screens = await window_size.getScreenList();
if (screens.isNotEmpty) {
final windowRect = Rect.fromLTWH(left, top, width, height);
bool isVisible = false;
for (final screen in screens) {
final intersection = windowRect.intersect(screen.visibleFrame);
if (intersection.width >= 10.0 && intersection.height >= 10.0) {
isVisible = true;
break;
}
}
if (!isVisible) {
return null;
}
return Offset(left, top);
}
}
if (frameLeft == null) {
frameLeft = 0.0;
frameTop = 0.0;
frameRight = ((isDesktop || isWebDesktop)
? kDesktopMaxDisplaySize
: kMobileMaxDisplaySize)
.toDouble();
frameBottom = ((isDesktop || isWebDesktop)
? kDesktopMaxDisplaySize
: kMobileMaxDisplaySize)
.toDouble();
}
double frameLeft = 0.0;
double frameTop = 0.0;
double frameRight = ((isDesktop || isWebDesktop)
? kDesktopMaxDisplaySize
: kMobileMaxDisplaySize)
.toDouble();
double frameBottom = ((isDesktop || isWebDesktop)
? kDesktopMaxDisplaySize
: kMobileMaxDisplaySize)
.toDouble();
final minWidth = 10.0;
if ((left + minWidth) > frameRight! ||
(top + minWidth) > frameBottom! ||
if ((left + minWidth) > frameRight ||
(top + minWidth) > frameBottom ||
(left + width - minWidth) < frameLeft ||
top < frameTop!) {
top < frameTop) {
return null;
} else {
return Offset(left, top);
@@ -2675,6 +2676,31 @@ class SimpleWrapper<T> {
SimpleWrapper(this.value);
}
/// Wakelock manager with reference counting for desktop.
/// Ensures wakelock is only disabled when all sessions are closed/minimized.
///
/// Note: Each isolate has its own WakelockPlus instance with independent assertion.
/// As long as one isolate has wakelock enabled, the screen stays awake.
/// This manager handles multiple tabs within the same isolate.
class WakelockManager {
static final Set<UniqueKey> _enabledKeys = {};
static void enable(UniqueKey key) {
if (isLinux) return;
_enabledKeys.add(key);
WakelockPlus.enable();
}
static void disable(UniqueKey key) {
if (isLinux) return;
if (_enabledKeys.remove(key)) {
if (_enabledKeys.isEmpty) {
WakelockPlus.disable();
}
}
}
}
/// call this to reload current window.
///
/// [Note]
@@ -2948,7 +2974,7 @@ Future<void> updateSystemWindowTheme() async {
///
/// Note: not found a general solution for rust based AVFoundation bingding.
/// [AVFoundation] crate has compile error.
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos");
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/host");
enum PermissionAuthorizeType {
undetermined,
@@ -3015,10 +3041,21 @@ Future<void> start_service(bool is_start) async {
}
Future<bool> canBeBlocked() async {
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
// First check control permission
final controlPermission = await bind.mainGetCommon(
key: "is-remote-modify-enabled-by-control-permissions");
if (controlPermission == "true") {
return false;
} else if (controlPermission == "false") {
return true;
}
// Check local settings
var accessMode = await bind.mainGetOption(key: kOptionAccessMode);
var isCustomAccessMode = accessMode != 'full' && accessMode != 'view';
var option = option2bool(kOptionAllowRemoteConfigModification,
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
return access_mode == 'view' || (access_mode.isEmpty && !option);
return accessMode == 'view' || (isCustomAccessMode && !option);
}
// to-do: web not implemented
@@ -3781,6 +3818,16 @@ setResizable(bool resizable) {
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
bool isChangePermanentPasswordDisabled() =>
bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) ==
'Y';
bool isChangeIdDisabled() =>
bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y';
bool isUnlockPinDisabled() =>
bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y';
bool? _isCustomClient;
bool get isCustomClient {
_isCustomClient ??= bind.isCustomClient();
@@ -4024,3 +4071,23 @@ String decode_http_response(http.Response resp) {
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
}
// TODO: We should support individual bits combinations in the future.
// But for now, just keep it simple, because the old code only supports single button.
// No users have requested multi-button support yet.
String mouseButtonsToPeer(int buttons) {
switch (buttons) {
case kPrimaryMouseButton:
return 'left';
case kSecondaryMouseButton:
return 'right';
case kMiddleMouseButton:
return 'wheel';
case kBackMouseButton:
return 'back';
case kForwardMouseButton:
return 'forward';
default:
return '';
}
}

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/scale.dart';
import 'package:flutter_hbb/common.dart';
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
/// Implementations must provide [ffi] and [onScaleChanged] getters.
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
/// FFI instance for session interaction
FFI get ffi;
/// Callback invoked when scale value changes
ValueChanged<int>? get onScaleChanged;
late int _scaleValue;
late final Debouncer<int> _debouncerScale;
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
double _scalePos = 0.0;
int get scaleValue => _scaleValue;
double get scalePos => _scalePos;
int mapPosToPercent(double p) => _mapPosToPercent(p);
static const int minPercent = kScaleCustomMinPercent;
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
static const int maxPercent = kScaleCustomMaxPercent;
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
// Clamp helper for local use
int _clampScale(int v) => clampCustomScalePercent(v);
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
int _mapPosToPercent(double p) {
if (p <= 0.0) return minPercent;
if (p >= 1.0) return maxPercent;
if (p <= pivotPos) {
final q = p / pivotPos; // 0..1
final v = minPercent + q * (pivotPercent - minPercent);
return _clampScale(v.round());
} else {
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
final v = pivotPercent + q * (maxPercent - pivotPercent);
return _clampScale(v.round());
}
}
// Map percent [5,1000] → normalized position [0,1]
double _mapPercentToPos(int percent) {
final p = _clampScale(percent);
if (p <= pivotPercent) {
final q = (p - minPercent) / (pivotPercent - minPercent);
return q * pivotPos;
} else {
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
return pivotPos + q * (1.0 - pivotPos);
}
}
// Snap normalized position to the pivot when close to it
double _snapNormalizedPos(double p) {
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
if (p < 0.0) return 0.0;
if (p > 1.0) return 1.0;
return p;
}
@override
void initState() {
super.initState();
_scaleValue = 100;
_debouncerScale = Debouncer<int>(
kDebounceCustomScaleDuration,
onChanged: (v) async {
await _applyScale(v);
},
initialValue: _scaleValue,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final v = await getSessionCustomScalePercent(ffi.sessionId);
if (mounted) {
setState(() {
_scaleValue = v;
_scalePos = _mapPercentToPos(v);
});
}
} catch (e, st) {
debugPrint('[CustomScale] Failed to get initial value: $e');
debugPrintStack(stackTrace: st);
}
});
}
Future<void> _applyScale(int v) async {
v = clampCustomScalePercent(v);
setState(() {
_scaleValue = v;
});
try {
await bind.sessionSetFlutterOption(
sessionId: ffi.sessionId,
k: kCustomScalePercentKey,
v: v.toString());
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
if (curStyle != kRemoteViewStyleCustom) {
await bind.sessionSetViewStyle(
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
}
await ffi.canvasModel.updateViewStyle();
if (isMobile) {
HapticFeedback.selectionClick();
}
onScaleChanged?.call(v);
} catch (e, st) {
debugPrint('[CustomScale] Apply failed: $e');
debugPrintStack(stackTrace: st);
}
}
void nudgeScale(int delta) {
final next = _clampScale(_scaleValue + delta);
setState(() {
_scaleValue = next;
_scalePos = _mapPercentToPos(next);
});
onScaleChanged?.call(next);
_debouncerScale.value = next;
}
@override
void dispose() {
_debouncerScale.cancel();
super.dispose();
}
void onSliderChanged(double v) {
final snapped = _snapNormalizedPos(v);
final next = _mapPosToPercent(snapped);
if (next != _scaleValue || snapped != _scalePos) {
setState(() {
_scalePos = snapped;
_scaleValue = next;
});
onScaleChanged?.call(next);
_debouncerScale.value = next;
}
}
}

View File

@@ -7,20 +7,29 @@ import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:flutter_hbb/utils/http_service.dart' as http;
import '../../common.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import 'address_book.dart';
void clientClose(SessionID sessionId, OverlayDialogManager dialogManager) {
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
'', dialogManager);
void clientClose(SessionID sessionId, FFI ffi) async {
if (allowAskForNoteAtEndOfConnection(ffi, true)) {
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
return;
}
closeConnection();
} else {
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
'', ffi.dialogManager);
}
}
abstract class ValidationRule {
@@ -1509,56 +1518,71 @@ showSetOSAccount(
});
}
Widget buildNoteTextField({
required TextEditingController controller,
required VoidCallback onEscape,
}) {
final focusNode = FocusNode(
onKey: (FocusNode node, RawKeyEvent evt) {
if (evt.logicalKey.keyLabel == 'Enter') {
if (evt is RawKeyDownEvent) {
int pos = controller.selection.base.offset;
controller.text =
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
controller.selection =
TextSelection.fromPosition(TextPosition(offset: pos + 1));
}
return KeyEventResult.handled;
}
if (evt.logicalKey.keyLabel == 'Esc') {
if (evt is RawKeyDownEvent) {
onEscape();
}
return KeyEventResult.handled;
} else {
return KeyEventResult.ignored;
}
},
);
return TextField(
autofocus: true,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: translate('input note here'),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: EdgeInsets.all(12),
),
minLines: 5,
maxLines: null,
maxLength: 256,
controller: controller,
focusNode: focusNode,
).workaroundFreezeLinuxMint();
}
showAuditDialog(FFI ffi) async {
final controller = TextEditingController(text: ffi.auditNote);
final controller = TextEditingController(
text: bind.sessionGetLastAuditNote(sessionId: ffi.sessionId));
ffi.dialogManager.show((setState, close, context) {
submit() {
var text = controller.text;
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
ffi.auditNote = text;
close();
}
late final focusNode = FocusNode(
onKey: (FocusNode node, RawKeyEvent evt) {
if (evt.logicalKey.keyLabel == 'Enter') {
if (evt is RawKeyDownEvent) {
int pos = controller.selection.base.offset;
controller.text =
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
controller.selection =
TextSelection.fromPosition(TextPosition(offset: pos + 1));
}
return KeyEventResult.handled;
}
if (evt.logicalKey.keyLabel == 'Esc') {
if (evt is RawKeyDownEvent) {
close();
}
return KeyEventResult.handled;
} else {
return KeyEventResult.ignored;
}
},
);
return CustomAlertDialog(
title: Text(translate('Note')),
content: SizedBox(
width: 250,
height: 120,
child: TextField(
autofocus: true,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration.collapsed(
hintText: 'input note here',
),
maxLines: null,
maxLength: 256,
child: buildNoteTextField(
controller: controller,
focusNode: focusNode,
).workaroundFreezeLinuxMint()),
onEscape: close,
)),
actions: [
dialogButton('Cancel', onPressed: close, isOutline: true),
dialogButton('OK', onPressed: submit)
@@ -1569,6 +1593,223 @@ showAuditDialog(FFI ffi) async {
});
}
bool allowAskForNoteAtEndOfConnection(FFI? ffi, bool closedByControlling) {
if (ffi == null) {
return false;
}
return mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection) &&
bind
.sessionGetAuditServerSync(sessionId: ffi.sessionId, typ: "conn")
.isNotEmpty &&
bind.sessionGetAuditGuid(sessionId: ffi.sessionId).isNotEmpty &&
bind.sessionGetLastAuditNote(sessionId: ffi.sessionId).isEmpty &&
(!closedByControlling ||
bind.willSessionCloseCloseSession(sessionId: ffi.sessionId));
}
// return value: close canceled
// true: return
// false: go on
Future<bool> desktopTryShowTabAuditDialogCloseCancelled(
{required String id, required DesktopTabController tabController}) async {
try {
final page =
tabController.state.value.tabs.firstWhere((tab) => tab.key == id).page;
final ffi = (page as dynamic).ffi;
final res = await showConnEndAuditDialogCloseCanceled(ffi: ffi);
return res;
} catch (e) {
debugPrint('Failed to show audit dialog: $e');
return false;
}
}
// return value:
// true: return
// false: go on
Future<bool> showConnEndAuditDialogCloseCanceled(
{required FFI ffi, String? type, String? title, String? text}) async {
final res = await _showConnEndAuditDialogCloseCanceled(
ffi: ffi, type: type, title: title, text: text);
if (res == true) {
return true;
}
return false;
}
// return value:
// true: return
// false / null: go on
Future<bool?> _showConnEndAuditDialogCloseCanceled({
required FFI ffi,
String? type,
String? title,
String? text,
}) async {
final closedByControlling = type == null;
final showDialog = allowAskForNoteAtEndOfConnection(ffi, closedByControlling);
if (!showDialog) {
return false;
}
ffi.dialogManager.dismissAll();
Future<void> updateAuditNoteByGuid(String auditGuid, String note) async {
debugPrint('Updating audit note for GUID: $auditGuid, note: $note');
try {
final apiServer = await bind.mainGetApiServer();
if (apiServer.isEmpty) {
debugPrint('API server is empty, cannot update audit note');
return;
}
final url = '$apiServer/api/audit';
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
final body = jsonEncode({
'guid': auditGuid,
'note': note,
});
final response = await http.put(
Uri.parse(url),
headers: headers,
body: body,
);
if (response.statusCode == 200) {
debugPrint('Successfully updated audit note for GUID: $auditGuid');
} else {
debugPrint(
'Failed to update audit note. Status: ${response.statusCode}, Body: ${response.body}');
}
} catch (e) {
debugPrint('Error updating audit note: $e');
}
}
final controller = TextEditingController();
bool askForNote =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
final isOptFixed = isOptionFixed(kOptionAllowAskForNoteAtEndOfConnection);
bool isInProgress = false;
return await ffi.dialogManager.show<bool>((setState, close, context) {
cancel() {
close(true);
}
set() async {
if (isInProgress) return;
setState(() {
isInProgress = true;
});
var text = controller.text;
if (text.isNotEmpty) {
await updateAuditNoteByGuid(
bind.sessionGetAuditGuid(sessionId: ffi.sessionId), text)
.timeout(const Duration(seconds: 6), onTimeout: () {
debugPrint('updateAuditNoteByGuid timeout after 6s');
});
}
// Save the "ask for note" preference
if (!isOptFixed) {
await mainSetLocalBoolOption(
kOptionAllowAskForNoteAtEndOfConnection, askForNote);
}
}
submit() async {
await set();
close(false);
}
final buttons = [
dialogButton('OK', onPressed: isInProgress ? null : submit)
];
if (type == 'relay-hint' || type == 'relay-hint2') {
buttons.add(dialogButton('Retry', onPressed: () async {
await set();
close(true);
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, false);
}));
if (type == 'relay-hint2') {
buttons.add(dialogButton('Connect via relay', onPressed: () async {
await set();
close(true);
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, true);
}));
}
}
if (closedByControlling) {
buttons.add(dialogButton('Cancel',
onPressed: isInProgress ? null : cancel, isOutline: true));
}
Widget content;
if (closedByControlling) {
content = SelectionArea(
child: msgboxContent(
'info', 'Close', 'Are you sure to close the connection?'));
} else {
content =
SelectionArea(child: msgboxContent(type, title ?? '', text ?? ''));
}
return CustomAlertDialog(
title: null,
content: SizedBox(
width: 350,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
content,
const SizedBox(height: 16),
SizedBox(
height: 120,
child: buildNoteTextField(
controller: controller,
onEscape: cancel,
),
),
if (!isOptFixed) ...[
const SizedBox(height: 8),
InkWell(
onTap: () {
setState(() {
askForNote = !askForNote;
});
},
child: Row(
children: [
Checkbox(
value: askForNote,
onChanged: (value) {
setState(() {
askForNote = value ?? false;
});
},
),
Expanded(
child: Text(
translate('note-at-conn-end-tip'),
style: const TextStyle(fontSize: 13),
),
),
],
),
),
],
if (isInProgress)
const LinearProgressIndicator().marginOnly(top: 4),
],
)),
actions: buttons,
onSubmit: submit,
onCancel: cancel,
);
});
}
void showConfirmSwitchSidesDialog(
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
dialogManager.show((setState, close, context) {

View File

@@ -400,6 +400,8 @@ Future<bool?> loginDialog() async {
String? passwordMsg;
var isInProgress = false;
final RxString curOP = ''.obs;
// Track hover state for the close icon
bool isCloseHovered = false;
final loginOptions = [].obs;
Future.delayed(Duration.zero, () async {
@@ -557,21 +559,27 @@ Future<bool?> loginDialog() async {
Text(
translate('Login'),
).marginOnly(top: MyTheme.dialogPadding),
InkWell(
child: Icon(
Icons.close,
size: 25,
// No need to handle the branch of null.
// Because we can ensure the color is not null when debug.
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.55),
MouseRegion(
onEnter: (_) => setState(() => isCloseHovered = true),
onExit: (_) => setState(() => isCloseHovered = false),
child: InkWell(
child: Icon(
Icons.close,
size: 25,
// No need to handle the branch of null.
// Because we can ensure the color is not null when debug.
color: isCloseHovered
? Colors.white
: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.55),
),
onTap: onDialogCancel,
hoverColor: Colors.red,
borderRadius: BorderRadius.circular(5),
),
onTap: onDialogCancel,
hoverColor: Colors.red,
borderRadius: BorderRadius.circular(5),
).marginOnly(top: 10, right: 15),
],
);

View File

@@ -50,6 +50,7 @@ class DraggableChatWindow extends StatelessWidget {
)
: Draggable(
checkKeyboard: true,
checkScreenSize: true,
position: draggablePositions.chatWindow,
width: width,
height: height,
@@ -395,7 +396,10 @@ class _DraggableState extends State<Draggable> {
_chatModel?.setChatWindowPosition(position);
}
checkScreenSize() {}
checkScreenSize() {
// Ensure the draggable always stays within current screen bounds
widget.position.tryAdjust(widget.width, widget.height, 1);
}
checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
@@ -517,6 +521,12 @@ class IOSDraggableState extends State<IOSDraggable> {
_lastBottomHeight = bottomHeight;
}
@override
void initState() {
super.initState();
position.tryAdjust(_width, _height, 1);
}
@override
Widget build(BuildContext context) {
checkKeyboard();

View File

@@ -372,7 +372,10 @@ class _RawTouchGestureDetectorRegionState
await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
}
await inputModel.sendMouse('down', MouseButtons.left);
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('down', MouseButtons.left);
}
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else {
final offset = ffi.cursorModel.offset;
@@ -397,7 +400,12 @@ class _RawTouchGestureDetectorRegionState
if (handleTouch && !_touchModePanStarted) {
return;
}
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
// In relative mouse mode, send delta directly without position tracking.
if (inputModel.relativeMouseMode.value) {
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
} else {
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
}
}
onOneFingerPanEnd(DragEndDetails d) async {
@@ -409,7 +417,10 @@ class _RawTouchGestureDetectorRegionState
ffi.cursorModel.clearRemoteWindowCoords();
}
if (handleTouch) {
await inputModel.sendMouse('up', MouseButtons.left);
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('up', MouseButtons.left);
}
}
}

View File

@@ -6,10 +6,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/common/widgets/login.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
bool isEditOsPassword = false;
@@ -193,14 +195,26 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// note
if (isDefaultConn &&
bind
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
.isNotEmpty) {
if (isDefaultConn && !bind.isDisableAccount()) {
v.add(
TTextMenu(
child: Text(translate('Note')),
onPressed: () => showAuditDialog(ffi)),
onPressed: () async {
bool isLogin =
bind.mainGetLocalOption(key: 'access_token').isNotEmpty;
if (!isLogin) {
final res = await loginDialog();
if (res != true) return;
// Desktop: send message to main window to refresh login status
// Web: login is required before connection, so no need to refresh
// Mobile: same isolate, no need to send message
if (isDesktop) {
rustDeskWinManager.call(
WindowType.Main, kWindowRefreshCurrentUser, "");
}
}
showAuditDialog(ffi);
}),
);
}
// divider
@@ -364,12 +378,11 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
value: kRemoteViewStyleAdaptive,
groupValue: groupValue,
onChanged: onChanged),
if (isDesktop || isWebDesktop)
TRadioMenu<String>(
child: Text(translate('Scale custom')),
value: kRemoteViewStyleCustom,
groupValue: groupValue,
onChanged: onChanged)
TRadioMenu<String>(
child: Text(translate('Scale custom')),
value: kRemoteViewStyleCustom,
groupValue: groupValue,
onChanged: onChanged)
];
}
@@ -818,6 +831,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
List<TToggleMenu> v = [];
// swap key
@@ -839,6 +853,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
child: Text(translate('Swap control-command key'))));
}
// Relative mouse mode (gaming mode).
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
// Note: This feature is only available in Flutter client. Sciter client does not support this.
// Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
// Wayland is not supported due to cursor warping limitations.
// Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
if (isDesktop &&
isDefaultConn &&
!isWeb &&
!isWayland &&
ffiModel.keyboard &&
!ffiModel.viewOnly &&
ffi.inputModel.isRelativeMouseModeSupported) {
v.add(TToggleMenu(
value: ffi.inputModel.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
final previousValue = ffi.inputModel.relativeMouseMode.value;
final success = ffi.inputModel.setRelativeMouseMode(value);
if (!success) {
// Revert the observable toggle to reflect the actual state
ffi.inputModel.relativeMouseMode.value = previousValue;
}
},
child: Text(translate('Relative mouse mode'))));
}
// reverse mouse wheel
if (ffiModel.keyboard) {
var optionValue =

View File

@@ -50,6 +50,7 @@ const String kAppTypeDesktopPortForward = "port forward";
const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed.
@@ -58,6 +59,7 @@ const String kWindowActionRebuild = "rebuild";
const String kWindowEventHide = "hide";
const String kWindowEventShow = "show";
const String kWindowConnect = "connect";
const String kWindowBumpMouse = "bump_mouse";
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
const String kWindowEventNewFileTransfer = "new_file_transfer";
@@ -78,6 +80,7 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session";
const String kOptionViewStyle = "view_style";
const String kOptionScrollStyle = "scroll_style";
const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
const String kOptionImageQuality = "image_quality";
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
const String kOptionTextureRender = "use-texture-render";
@@ -118,6 +121,7 @@ const String kOptionApproveMode = "approve-mode";
const String kOptionAllowNumericOneTimePassword =
"allow-numeric-one-time-password";
const String kOptionCollapseToolbar = "collapse_toolbar";
const String kOptionHideToolbar = "hide-toolbar";
const String kOptionShowRemoteCursor = "show_remote_cursor";
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
const String kOptionFollowRemoteWindow = "follow_remote_window";
@@ -158,11 +162,16 @@ const String kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options
const String kOptionAllowWebSocket = "allow-websocket";
const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback";
const String kOptionDisableUdp = "disable-udp";
const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
// buildin opitons
// builtin options
const String kOptionHideServerSetting = "hide-server-settings";
const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
@@ -171,6 +180,10 @@ const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning =
"remove-preset-password-warning";
const String kOptionDisableChangePermanentPassword =
"disable-change-permanent-password";
const String kOptionDisableChangeId = "disable-change-id";
const String kOptionDisableUnlockPin = "disable-unlock-pin";
const kHideUsernameOnCard = "hide-username-on-card";
const String kOptionHideHelpCards = "hide-help-cards";
@@ -245,6 +258,33 @@ const int kMinTrackpadSpeed = 10;
const int kDefaultTrackpadSpeed = 100;
const int kMaxTrackpadSpeed = 1000;
// relative mouse mode
/// Throttle duration (in milliseconds) for updating pointer lock center during
/// window move/resize events. Lower values provide more responsive updates but
/// may cause performance issues during rapid window operations.
const int kDefaultPointerLockCenterThrottleMs = 100;
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
/// Servers older than this version will ignore relative mouse events.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
const String kMinVersionForRelativeMouseMode = '1.4.5';
/// Maximum delta value for relative mouse movement.
/// Large values could cause issues with i32 overflow on server side,
/// and no reasonable mouse movement should exceed this bound.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
const int kMaxRelativeMouseDelta = 10000;
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
/// This prevents double-toggle from race condition between Rust rdev grab loop
/// and Flutter keyboard handling. Value should be small enough to allow
/// intentional quick toggles but large enough to prevent accidental double-triggers.
const int kRelativeMouseModeToggleDebounceMs = 150;
// incomming (should be incoming) is kept, because change it will break the previous setting.
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
const String kValuePrinterIncomingJobDismiss = 'dismiss';
@@ -319,13 +359,15 @@ const kRemoteViewStyleAdaptive = 'adaptive';
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
const kRemoteViewStyleCustom = 'custom';
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
const kRemoteScrollStyleAuto = 'scrollauto';
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
const kRemoteScrollStyleBar = 'scrollbar';
/// [kRemoteScrollStyleEdge] Scroll image auto at edges.
const kRemoteScrollStyleEdge = 'scrolledge';
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
const kScrollModeDefault = 'default';
@@ -353,12 +395,14 @@ const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
};
// Scale custom related constants
const String kCustomScalePercentKey = 'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
const String kCustomScalePercentKey =
'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
const int kScaleCustomMinPercent = 5;
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
const int kScaleCustomMaxPercent = 1000;
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
const double kScaleCustomDetentEpsilon = 0.006; // snap range around pivot (~0.6%)
const double kScaleCustomDetentEpsilon =
0.006; // snap range around pivot (~0.6%)
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
// ================================ mobile ================================

View File

@@ -18,6 +18,7 @@ import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/plugin/ui_manager.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/platform_channel.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -760,11 +761,23 @@ class _DesktopHomePageState extends State<DesktopHomePage>
'scaleFactor': screen.scaleFactor,
};
bool isChattyMethod(String methodName) {
switch (methodName) {
case kWindowBumpMouse: return true;
}
return false;
}
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
debugPrint(
if (!isChattyMethod(call.method)) {
debugPrint(
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
}
if (call.method == kWindowMainWindowOnTop) {
windowOnTop(null);
} else if (call.method == kWindowRefreshCurrentUser) {
gFFI.userModel.refreshCurrentUser();
} else if (call.method == kWindowGetWindowInfo) {
final screen = (await window_size.getWindowInfo()).screen;
if (screen == null) {
@@ -793,6 +806,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
forceRelay: call.arguments['forceRelay'],
connToken: call.arguments['connToken'],
);
} else if (call.method == kWindowBumpMouse) {
return RdPlatformChannel.instance.bumpMouse(
dx: call.arguments['dx'],
dy: call.arguments['dy']);
} else if (call.method == kWindowEventMoveTabToNewWindow) {
final args = call.arguments.split(',');
int? windowId;

View File

@@ -11,6 +11,7 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.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';
@@ -560,6 +561,21 @@ class _GeneralState extends State<_General> {
children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
}
if (!bind.isDisableAccount()) {
children.add(_OptionCheckBox(
context,
'note-at-conn-end-tip',
kOptionAllowAskForNoteAtEndOfConnection,
isServer: false,
optSetter: (key, value) async {
if (value && !gFFI.userModel.isLogin) {
final res = await loginDialog();
if (res != true) return;
}
await mainSetLocalBoolOption(key, value);
},
));
}
return _Card(title: 'Other', children: children);
}
@@ -809,7 +825,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
permissions(context),
password(context),
_Card(title: '2FA', children: [tfa()]),
_Card(title: 'ID', children: [changeId()]),
if (!isChangeIdDisabled())
_Card(title: 'ID', children: [changeId()]),
more(context),
]),
),
@@ -1075,6 +1092,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetPermanentPassword())
.isEmpty) {
if (isChangePermanentPasswordDisabled()) {
await callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
@@ -1177,9 +1198,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
],
),
enabled: tmpEnabled && !locked),
numericOneTimePassword,
if (usePassword) numericOneTimePassword,
if (usePassword) radios[1],
if (usePassword)
if (usePassword && !isChangePermanentPasswordDisabled())
_SubButton('Set permanent password', setPasswordDialog,
permEnabled && !locked),
// if (usePassword)
@@ -1202,7 +1223,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
'allow-only-conn-window-open',
reverse: false, enabled: enabled),
if (bind.mainIsInstalled()) unlockPin()
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
]);
}
@@ -1585,6 +1606,27 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
);
}
Widget switchWidget(IconData icon, String title, String tooltipMessage,
String optionKey) =>
listTile(
icon: icon,
title: title,
showTooltip: true,
tooltipMessage: tooltipMessage,
trailing: Switch(
value: mainGetBoolOptionSync(optionKey),
onChanged: locked || isOptionFixed(optionKey)
? null
: (value) {
mainSetBoolOption(optionKey, value);
setState(() {});
},
),
);
final outgoingOnly = bind.isOutgoingOnly();
final divider = const Divider(height: 1, indent: 16, endIndent: 16);
return _Card(
title: 'Network',
children: [
@@ -1596,33 +1638,65 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
listTile(
icon: Icons.dns_outlined,
title: 'ID/Relay Server',
onTap: () => showServerSettings(gFFI.dialogManager),
onTap: () => showServerSettings(gFFI.dialogManager, setState),
),
if (!hideServer && (!hideProxy || !hideWebSocket))
Divider(height: 1, indent: 16, endIndent: 16),
if (!hideProxy && !hideServer) divider,
if (!hideProxy)
listTile(
icon: Icons.network_ping_outlined,
title: 'Socks5/Http(s) Proxy',
onTap: changeSocks5Proxy,
),
if (!hideProxy && !hideWebSocket)
Divider(height: 1, indent: 16, endIndent: 16),
if (!hideWebSocket && (!hideServer || !hideProxy)) divider,
if (!hideWebSocket)
listTile(
icon: Icons.web_asset_outlined,
title: 'Use WebSocket',
showTooltip: true,
tooltipMessage: 'websocket_tip',
trailing: Switch(
value: mainGetBoolOptionSync(kOptionAllowWebSocket),
onChanged: locked
? null
: (value) {
mainSetBoolOption(kOptionAllowWebSocket, value);
setState(() {});
},
),
switchWidget(
Icons.web_asset_outlined,
'Use WebSocket',
'${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}',
kOptionAllowWebSocket),
if (!isWeb)
futureBuilder(
future: bind.mainIsUsingPublicServer(),
hasData: (isUsingPublicServer) {
if (isUsingPublicServer) {
return Offstage();
} else {
return Column(
children: [
if (!hideServer || !hideProxy || !hideWebSocket)
divider,
switchWidget(
Icons.no_encryption_outlined,
'Allow insecure TLS fallback',
'allow-insecure-tls-fallback-tip',
kOptionAllowInsecureTLSFallback),
if (!outgoingOnly) divider,
if (!outgoingOnly)
listTile(
icon: Icons.lan_outlined,
title: 'Disable UDP',
showTooltip: true,
tooltipMessage:
'${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}',
trailing: Switch(
value: bind.mainGetOptionSync(
key: kOptionDisableUdp) ==
'Y',
onChanged:
locked || isOptionFixed(kOptionDisableUdp)
? null
: (value) async {
await bind.mainSetOption(
key: kOptionDisableUdp,
value: value ? 'Y' : 'N');
setState(() {});
},
),
),
],
);
}
},
),
],
),
@@ -1685,6 +1759,13 @@ class _DisplayState extends State<_Display> {
}
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
onEdgeScrollEdgeThicknessChanged(double value) async {
await bind.mainSetUserDefaultOption(
key: kOptionEdgeScrollEdgeThickness, value: value.round().toString());
setState(() {});
}
return _Card(title: 'Default Scroll Style', children: [
_Radio(context,
value: kRemoteScrollStyleAuto,
@@ -1696,6 +1777,23 @@ class _DisplayState extends State<_Display> {
groupValue: groupValue,
label: 'Scrollbar',
onChanged: isOptFixed ? null : onChanged),
if (!isWeb) ...[
_Radio(context,
value: kRemoteScrollStyleEdge,
groupValue: groupValue,
label: 'ScrollEdge',
onChanged: isOptFixed ? null : onChanged),
Offstage(
offstage: groupValue != kRemoteScrollStyleEdge,
child: EdgeThicknessControl(
value: double.tryParse(bind.mainGetUserDefaultOption(
key: kOptionEdgeScrollEdgeThickness)) ??
100.0,
onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness)
? null
: onEdgeScrollEdgeThicknessChanged,
)),
],
]);
}
@@ -1737,9 +1835,9 @@ class _DisplayState extends State<_Display> {
}
Widget trackpadSpeed(BuildContext context) {
final initSpeed = (int.tryParse(
bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
kDefaultTrackpadSpeed);
final initSpeed =
(int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
kDefaultTrackpadSpeed);
final curSpeed = SimpleWrapper(initSpeed);
void onDebouncer(int v) {
bind.mainSetUserDefaultOption(
@@ -2561,7 +2659,7 @@ Widget _lock(
]).marginSymmetric(vertical: 2)),
onPressed: () async {
final unlockPin = bind.mainGetUnlockPin();
if (unlockPin.isEmpty) {
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
bool checked = await callMainCheckSuperUserPermission();
if (checked) {
onUnlock();

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math';
import 'package:extended_text/extended_text.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
import 'package:percent_indicator/percent_indicator.dart';
import 'package:desktop_drop/desktop_drop.dart';
@@ -16,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
@@ -52,7 +52,7 @@ enum MouseFocusScope {
}
class FileManagerPage extends StatefulWidget {
const FileManagerPage(
FileManagerPage(
{Key? key,
required this.id,
required this.password,
@@ -67,9 +67,16 @@ class FileManagerPage extends StatefulWidget {
final bool? forceRelay;
final String? connToken;
final DesktopTabController? tabController;
final SimpleWrapper<State<FileManagerPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _FileManagerPageState)._ffi;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
State<StatefulWidget> createState() {
final state = _FileManagerPageState();
_lastState.value = state;
return state;
}
}
class _FileManagerPageState extends State<FileManagerPage>
@@ -78,6 +85,7 @@ class _FileManagerPageState extends State<FileManagerPage>
final _dropMaskVisible = false.obs; // TODO impl drop mask
final _overlayKeyState = OverlayKeyState();
final _uniqueKey = UniqueKey();
late FFI _ffi;
@@ -99,9 +107,7 @@ class _FileManagerPageState extends State<FileManagerPage>
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
if (!isLinux) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
if (isWeb) {
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@@ -119,9 +125,7 @@ class _FileManagerPageState extends State<FileManagerPage>
model.close().whenComplete(() {
_ffi.close();
_ffi.dialogManager.dismissAll();
if (!isLinux) {
WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
Get.delete<FFI>(tag: 'ft_${widget.id}');
});
WidgetsBinding.instance.removeObserver(this);
@@ -139,12 +143,26 @@ class _FileManagerPageState extends State<FileManagerPage>
}
}
Widget willPopScope(Widget child) {
if (isWeb) {
return WillPopScope(
onWillPop: () async {
clientClose(_ffi.sessionId, _ffi);
return false;
},
child: child,
);
} else {
return child;
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return Overlay(key: _overlayKeyState.key, initialEntries: [
OverlayEntry(builder: (_) {
return Scaffold(
return willPopScope(Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Row(
children: [
@@ -160,7 +178,7 @@ class _FileManagerPageState extends State<FileManagerPage>
Flexible(flex: 2, child: statusList())
],
),
);
));
})
]);
}
@@ -260,11 +278,9 @@ class _FileManagerPageState extends State<FileManagerPage>
item.state != JobState.inProgress,
child: LinearPercentIndicator(
animateFromLastPercent: true,
center: Text(
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
),
center: Text(item.percentText),
barRadius: Radius.circular(15),
percent: item.finishedSize / item.totalSize,
percent: item.percent,
progressColor: MyTheme.accent,
backgroundColor: Theme.of(context).hoverColor,
lineHeight: kDesktopFileTransferRowHeight,

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
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/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
@@ -40,7 +41,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
label: params['id'],
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(params['id']),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: params['id'],
tabController: tabController,
)) {
return;
}
tabController.closeBy(params['id']);
},
page: FileManagerPage(
key: ValueKey(params['id']),
id: params['id'],
@@ -69,7 +78,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(id),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: FileManagerPage(
key: ValueKey(id),
id: id,
@@ -132,6 +149,14 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;

View File

@@ -25,7 +25,7 @@ class _PortForward {
}
class PortForwardPage extends StatefulWidget {
const PortForwardPage({
PortForwardPage({
Key? key,
required this.id,
required this.password,
@@ -42,9 +42,16 @@ class PortForwardPage extends StatefulWidget {
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final SimpleWrapper<State<PortForwardPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi;
@override
State<PortForwardPage> createState() => _PortForwardPageState();
State<PortForwardPage> createState() {
final state = _PortForwardPageState();
_lastState.value = state;
return state;
}
}
class _PortForwardPageState extends State<PortForwardPage>

View File

@@ -3,9 +3,9 @@ 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/scheduler.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';
@@ -15,6 +15,7 @@ import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/toolbar.dart';
import '../../models/model.dart';
import '../../models/input_model.dart';
import '../../models/platform_model.dart';
import '../../common/shared_state.dart';
import '../../utils/image.dart';
@@ -72,7 +73,10 @@ class RemotePage extends StatefulWidget {
}
class _RemotePageState extends State<RemotePage>
with AutomaticKeepAliveClientMixin, MultiWindowListener {
with
AutomaticKeepAliveClientMixin,
MultiWindowListener,
TickerProviderStateMixin {
Timer? _timer;
String keyboardMode = "legacy";
bool _isWindowBlur = false;
@@ -81,11 +85,16 @@ class _RemotePageState extends State<RemotePage>
late RxBool _zoomCursor;
late RxBool _remoteCursorMoved;
late RxBool _keyboardEnabled;
final _uniqueKey = UniqueKey();
var _blockableOverlayState = BlockableOverlayState();
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// Debounce timer for pointer lock center updates during window events.
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
Timer? _pointerLockCenterDebounceTimer;
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
@@ -112,11 +121,13 @@ class _RemotePageState extends State<RemotePage>
_ffi = FFI(widget.sessionId);
Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor();
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
_ffi.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
});
_ffi.canvasModel.initializeEdgeScrollFallback(this);
_ffi.start(
widget.id,
password: widget.password,
@@ -132,9 +143,7 @@ class _RemotePageState extends State<RemotePage>
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
if (!isLinux) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
@@ -165,6 +174,16 @@ class _RemotePageState extends State<RemotePage>
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id);
});
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
}
/// Cancel the pointer lock center debounce timer
void _cancelPointerLockCenterDebounceTimer() {
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
}
@override
@@ -180,6 +199,13 @@ class _RemotePageState extends State<RemotePage>
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
// When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally.
// The cursor will be re-hidden and re-centered when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
}
@override
@@ -190,6 +216,12 @@ class _RemotePageState extends State<RemotePage>
_isWindowBlur = false;
}
stateGlobal.isFocused.value = true;
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_rawKeyFocusNode.requestFocus();
_ffi.inputModel.onWindowFocus();
}
}
@override
@@ -200,25 +232,59 @@ class _RemotePageState extends State<RemotePage>
if (isWindows) {
_isWindowBlur = false;
}
if (!isLinux) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is restored
_updatePointerLockCenterIfNeeded();
}
// 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();
}
WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is maximized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowResize() {
super.onWindowResize();
// Update pointer lock center when window is resized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowMove() {
super.onWindowMove();
// Update pointer lock center when window is moved
_updatePointerLockCenterIfNeeded();
}
/// Update pointer lock center with debouncing to avoid excessive updates
/// during rapid window move/resize events.
void _updatePointerLockCenterIfNeeded() {
if (!_ffi.inputModel.relativeMouseMode.value) return;
// Cancel any pending update and schedule a new one (debounce pattern)
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = Timer(
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
() {
if (!mounted) return;
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.updatePointerLockCenter();
}
},
);
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
if (!isLinux) {
WakelockPlus.disable();
WakelockManager.disable(_uniqueKey);
// Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
}
@@ -245,6 +311,16 @@ class _RemotePageState extends State<RemotePage>
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
// Defensive cleanup: ensure host system-key propagation is reset even if
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
@@ -262,9 +338,7 @@ class _RemotePageState extends State<RemotePage>
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
if (!isLinux) {
await WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
await Get.delete<FFI>(tag: widget.id);
removeSharedStates(widget.id);
}
@@ -348,10 +422,15 @@ class _RemotePageState extends State<RemotePage>
}
}(),
// Use Overlay to enable rebuild every time on menu button click.
_ffi.ffiModel.pi.isSet.isTrue
? Overlay(
initialEntries: [OverlayEntry(builder: remoteToolbar)])
: remoteToolbar(context),
// Hide toolbar when relative mouse mode is active to prevent
// cursor from escaping to toolbar area.
Obx(() => _ffi.inputModel.relativeMouseMode.value
? const Offstage()
: _ffi.ffiModel.pi.isSet.isTrue
? Overlay(initialEntries: [
OverlayEntry(builder: remoteToolbar)
])
: remoteToolbar(context)),
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
],
),
@@ -395,7 +474,7 @@ class _RemotePageState extends State<RemotePage>
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi.dialogManager);
clientClose(sessionId, _ffi);
return false;
},
child: MultiProvider(providers: [
@@ -408,6 +487,8 @@ class _RemotePageState extends State<RemotePage>
}
void enterView(PointerEnterEvent evt) {
_ffi.canvasModel.rearmEdgeScroll();
_cursorOverImage.value = true;
_firstEnterImage.value = true;
if (_onEnterOrLeaveImage4Toolbar != null) {
@@ -417,6 +498,7 @@ class _RemotePageState extends State<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
@@ -427,6 +509,8 @@ class _RemotePageState extends State<RemotePage>
}
void leaveView(PointerExitEvent evt) {
_ffi.canvasModel.disableEdgeScroll();
if (_ffi.ffiModel.keyboard) {
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
}
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
@@ -487,33 +572,39 @@ class _RemotePageState extends State<RemotePage>
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,
zoomCursor: _zoomCursor,
cursorOverImage: _cursorOverImage,
keyboardEnabled: _keyboardEnabled,
remoteCursorMoved: _remoteCursorMoved,
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}))
MouseRegion(
onEnter: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
},
onExit: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
},
child: _ViewStyleUpdater(
canvasModel: _ffi.canvasModel,
inputModel: _ffi.inputModel,
child: Builder(builder: (context) {
final peerDisplay = CurrentDisplayState.find(widget.id);
return Obx(
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
zoomCursor: _zoomCursor,
cursorOverImage: _cursorOverImage,
keyboardEnabled: _keyboardEnabled,
remoteCursorMoved: _remoteCursorMoved,
listenerBuilder: (child) =>
_buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}),
),
)
];
if (!_ffi.canvasModel.cursorEmbedded) {
@@ -542,6 +633,63 @@ class _RemotePageState extends State<RemotePage>
bool get wantKeepAlive => true;
}
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
/// and InputModel.updateImageWidgetSize() only when size actually changes.
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
class _ViewStyleUpdater extends StatefulWidget {
final CanvasModel canvasModel;
final InputModel inputModel;
final Widget child;
const _ViewStyleUpdater({
Key? key,
required this.canvasModel,
required this.inputModel,
required this.child,
}) : super(key: key);
@override
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
}
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
Size? _lastSize;
bool _callbackScheduled = false;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
// Guard against infinite constraints (e.g., unconstrained ancestor).
if (!maxWidth.isFinite || !maxHeight.isFinite) {
return widget.child;
}
final newSize = Size(maxWidth, maxHeight);
if (_lastSize != newSize) {
_lastSize = newSize;
// Schedule the update for after the current frame to avoid setState during build.
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
// when size changes rapidly before any callback executes.
if (!_callbackScheduled) {
_callbackScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_callbackScheduled = false;
final currentSize = _lastSize;
if (mounted && currentSize != null) {
widget.canvasModel.updateViewStyle();
widget.inputModel.updateImageWidgetSize(currentSize);
}
});
}
}
return widget.child;
},
);
}
}
class ImagePaint extends StatefulWidget {
final FFI ffi;
final String id;
@@ -606,26 +754,29 @@ class _ImagePaintState extends State<ImagePaint> {
cursor: cursorOverImage.isTrue
? c.cursorEmbedded
? SystemMouseCursors.none
: keyboardEnabled.isTrue
? (() {
if (remoteCursorMoved.isTrue) {
_lastRemoteCursorMoved = true;
return SystemMouseCursors.none;
} else {
if (_lastRemoteCursorMoved) {
_lastRemoteCursorMoved = false;
_firstEnterImage.value = true;
}
return _buildCustomCursor(
context, getCursorScale());
}
}())
: _buildDisabledCursor(context, getCursorScale())
// Hide cursor when relative mouse mode is active
: widget.ffi.inputModel.relativeMouseMode.value
? SystemMouseCursors.none
: keyboardEnabled.isTrue
? (() {
if (remoteCursorMoved.isTrue) {
_lastRemoteCursorMoved = true;
return SystemMouseCursors.none;
} else {
if (_lastRemoteCursorMoved) {
_lastRemoteCursorMoved = false;
_firstEnterImage.value = true;
}
return _buildCustomCursor(
context, getCursorScale());
}
}())
: _buildDisabledCursor(context, getCursorScale())
: MouseCursor.defer,
onHover: (evt) {},
child: child);
});
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
final paintWidth = c.getDisplayWidth() * s;
final paintHeight = c.getDisplayHeight() * s;
final paintSize = Size(paintWidth, paintHeight);
@@ -680,9 +831,20 @@ class _ImagePaintState extends State<ImagePaint> {
Widget _buildScrollAutoNonTextureRender(
ImageModel m, CanvasModel c, double s) {
double sizeScale = s;
if (widget.ffi.ffiModel.isPeerLinux) {
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
if (displays.isNotEmpty) {
sizeScale = s / displays[0].scale;
}
}
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),
painter: ImagePainter(
image: m.image,
x: c.x / sizeScale,
y: c.y / sizeScale,
scale: sizeScale),
);
}
@@ -695,17 +857,19 @@ class _ImagePaintState extends State<ImagePaint> {
if (rect == null) {
return Container();
}
final isPeerLinux = ffiModel.isPeerLinux;
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
final sizeScale = isPeerLinux ? s / displays[i].scale : s;
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,
width: displays[i].width * sizeScale,
height: displays[i].height * sizeScale,
child: Obx(() => Texture(
textureId: textureId.value,
filterQuality:

View File

@@ -80,7 +80,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
label: peerId!,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(peerId),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: peerId!,
tabController: tabController,
)) {
return;
}
tabController.closeBy(peerId!);
},
page: RemotePage(
key: ValueKey(peerId),
id: peerId!,
@@ -127,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
tail: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RelativeMouseModeHint(tabController: tabController),
const AddButton(),
],
),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
@@ -243,11 +257,11 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
style: style,
)),
proc: () {
toolbarState.switchShow(sessionId);
toolbarState.switchHide(sessionId);
cancelFunc();
},
padding: padding,
@@ -316,7 +330,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
translate('Close'),
style: style,
),
proc: () {
proc: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: key,
tabController: tabController,
)) {
return;
}
tabController.closeBy(key);
cancelFunc();
},
@@ -360,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
loopCloseWindow();
}
ConnectionTypeState.delete(id);
// Clean up relative mouse mode state for this peer.
stateGlobal.relativeMouseModeState.remove(id);
_update_remote_count();
}
@@ -369,6 +391,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;
@@ -423,7 +453,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(id),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: RemotePage(
key: ValueKey(id),
id: id,
@@ -518,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
return returnValue;
}
}
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
/// This helps users remember how to exit relative mouse mode.
class _RelativeMouseModeHint extends StatelessWidget {
final DesktopTabController tabController;
const _RelativeMouseModeHint({Key? key, required this.tabController})
: super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
// Check if there are any tabs
if (tabController.state.value.tabs.isEmpty) {
return const SizedBox.shrink();
}
// Get current selected tab's RemotePage
final selectedTabInfo = tabController.state.value.selectedTabInfo;
if (selectedTabInfo.page is! RemotePage) {
return const SizedBox.shrink();
}
final remotePage = selectedTabInfo.page as RemotePage;
final String peerId = remotePage.id;
// Use global state to check relative mouse mode (synced from InputModel).
// This avoids timing issues with FFI registration.
final isRelativeMouseMode =
stateGlobal.relativeMouseModeState[peerId] ?? false;
if (!isRelativeMouseMode) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.mouse,
size: 14,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
translate(
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
style: TextStyle(
fontSize: 11,
color: Colors.orange[700],
),
),
],
),
);
});
}
}

View File

@@ -8,7 +8,7 @@ import 'package:xterm/xterm.dart';
import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
TerminalPage({
Key? key,
required this.id,
required this.password,
@@ -25,15 +25,23 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@override
State<TerminalPage> createState() => _TerminalPageState();
State<TerminalPage> createState() {
final state = _TerminalPageState();
_lastState.value = state;
return state;
}
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
@override
void initState() {
@@ -53,18 +61,30 @@ class _TerminalPageState extends State<TerminalPage>
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
// Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
}
});
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);
// Check if this is a new connection or additional terminal
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
final isExistingConnection =
TerminalConnectionManager.hasConnection(widget.id) &&
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
if (!isExistingConnection) {
// First terminal - show loading dialog, wait for onReady
_ffi.dialogManager
@@ -87,30 +107,48 @@ class _TerminalPageState extends State<TerminalPage>
super.dispose();
}
// This method ensures that the number of visible rows is an integer by computing the
// extra space left after dividing the available height by the height of a single
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final rows = (heightPx / _cellHeight!).floor();
final extraSpace = heightPx - rows * _cellHeight!;
final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
backgroundOpacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
body: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
);

View File

@@ -4,6 +4,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
@@ -62,13 +63,20 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}) {
final tabKey = '${peerId}_$terminalId';
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel = alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
return TabInfo(
key: tabKey,
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close the terminal session first
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi != null) {
@@ -409,6 +417,14 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;

View File

@@ -6,7 +6,6 @@ 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';
@@ -77,6 +76,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
String keyboardMode = "legacy";
bool _isWindowBlur = false;
final _cursorOverImage = false.obs;
final _uniqueKey = UniqueKey();
var _blockableOverlayState = BlockableOverlayState();
@@ -124,9 +124,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
if (!isLinux) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
@@ -185,26 +183,20 @@ class _ViewCameraPageState extends State<ViewCameraPage>
if (isWindows) {
_isWindowBlur = false;
}
if (!isLinux) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
}
// 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();
}
WakelockManager.enable(_uniqueKey);
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
if (!isLinux) {
WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
}
@override
@@ -247,9 +239,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
if (!isLinux) {
await WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
await Get.delete<FFI>(tag: widget.id);
removeSharedStates(widget.id);
}
@@ -360,7 +350,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi.dialogManager);
clientClose(sessionId, _ffi);
return false;
},
child: MultiProvider(providers: [
@@ -465,7 +455,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
widget.toolbarState.initShow(sessionId);
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
@@ -527,7 +516,7 @@ class _ImagePaintState extends State<ImagePaint> {
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
final paintWidth = c.getDisplayWidth() * s;
final paintHeight = c.getDisplayHeight() * s;
final paintSize = Size(paintWidth, paintHeight);

View File

@@ -6,6 +6,7 @@ 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/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
@@ -79,7 +80,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
label: peerId!,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(peerId),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: peerId!,
tabController: tabController,
)) {
return;
}
tabController.closeBy(peerId!);
},
page: ViewCameraPage(
key: ValueKey(peerId),
id: peerId!,
@@ -241,11 +250,11 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
style: style,
)),
proc: () {
toolbarState.switchShow(sessionId);
toolbarState.switchHide(sessionId);
cancelFunc();
},
padding: padding,
@@ -287,7 +296,13 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
translate('Close'),
style: style,
),
proc: () {
proc: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: key,
tabController: tabController,
)) {
return;
}
tabController.closeBy(key);
cancelFunc();
},
@@ -340,6 +355,14 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;
@@ -393,7 +416,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(id),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: ViewCameraPage(
key: ValueKey(id),
id: id,

View File

@@ -26,12 +26,17 @@ import '../../common/shared_state.dart';
import './popup_menu.dart';
import './kb_layout_type_chooser.dart';
import 'package:flutter_hbb/utils/scale.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
class ToolbarState {
late RxBool _pin;
bool isShowInited = false;
RxBool show = false.obs;
RxBool collapse = false.obs;
RxBool hide = false.obs;
// Track initialization state to prevent flickering
final RxBool initialized = false.obs;
bool _isInitializing = false;
ToolbarState() {
_pin = RxBool(false);
@@ -52,19 +57,39 @@ class ToolbarState {
bool get pin => _pin.value;
switchShow(SessionID sessionId) async {
bind.sessionToggleOption(
sessionId: sessionId, value: kOptionCollapseToolbar);
show.value = !show.value;
/// Initialize all toolbar states from session options.
/// This should be called once when the toolbar is first created.
Future<void> init(SessionID sessionId) async {
if (initialized.value || _isInitializing) return;
_isInitializing = true;
try {
// Load both states in parallel for better performance
final results = await Future.wait([
bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionCollapseToolbar),
bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionHideToolbar),
]);
collapse.value = results[0] ?? false;
hide.value = results[1] ?? false;
} finally {
_isInitializing = false;
initialized.value = true;
}
}
initShow(SessionID sessionId) async {
if (!isShowInited) {
show.value = !(await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionCollapseToolbar) ??
false);
isShowInited = true;
}
switchCollapse(SessionID sessionId) async {
bind.sessionToggleOption(
sessionId: sessionId, value: kOptionCollapseToolbar);
collapse.value = !collapse.value;
}
// Switch hide state for entire toolbar visibility
switchHide(SessionID sessionId) async {
bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar);
hide.value = !hide.value;
}
switchPin() async {
@@ -236,7 +261,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
// setState(() {});
}
RxBool get show => widget.state.show;
RxBool get collapse => widget.state.collapse;
RxBool get hide => widget.state.hide;
bool get pin => widget.state.pin;
PeerInfo get pi => widget.ffi.ffiModel.pi;
@@ -257,6 +283,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
arg: 'remote-menubar-drag-x') ??
'0.5') ??
0.5;
// Initialize toolbar states (collapse, hide) from session options
widget.state.init(widget.ffi.sessionId);
});
_debouncerHide = Debouncer<int>(
@@ -276,8 +304,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
}
_debouncerHideProc(int v) {
if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) {
show.value = false;
if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) {
collapse.value = true;
}
}
@@ -290,17 +318,27 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: Obx(() => show.value
? _buildToolbar(context)
: _buildDraggableShowHide(context)),
);
return Obx(() {
// Wait for initialization to complete to prevent flickering
if (!widget.state.initialized.value) {
return const SizedBox.shrink();
}
// If toolbar is hidden, return empty widget
if (hide.value) {
return const SizedBox.shrink();
}
return Align(
alignment: Alignment.topCenter,
child: collapse.isFalse
? _buildToolbar(context)
: _buildDraggableCollapse(context),
);
});
}
Widget _buildDraggableShowHide(BuildContext context) {
Widget _buildDraggableCollapse(BuildContext context) {
return Obx(() {
if (show.isTrue && _dragging.isFalse) {
if (collapse.isFalse && _dragging.isFalse) {
triggerAutoHide();
}
final borderRadius = BorderRadius.vertical(
@@ -397,7 +435,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
),
),
),
_buildDraggableShowHide(context),
_buildDraggableCollapse(context),
],
);
}
@@ -510,7 +548,7 @@ class _MonitorMenu extends StatelessWidget {
menuStyle: MenuStyle(
padding:
MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))),
menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]);
menuChildrenGetter: (_) => [buildMonitorSubmenuWidget(context)]);
}
Widget buildMultiMonitorMenu(BuildContext context) {
@@ -721,7 +759,7 @@ class _ControlMenu extends StatelessWidget {
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
ffi: ffi,
menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) {
menuChildrenGetter: (_) => toolbarControls(context, id, ffi).map((e) {
if (e.divider) {
return Divider();
} else {
@@ -932,12 +970,13 @@ class _DisplayMenuState extends State<_DisplayMenu> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
_screenAdjustor.updateScreen();
menuChildrenGetter() {
menuChildrenGetter(_IconSubmenuButtonState state) {
final menuChildren = <Widget>[
_screenAdjustor.adjustWindow(context),
viewStyle(customPercent: _customPercent),
scrollStyle(),
scrollStyle(state, colorScheme),
imageQuality(),
codec(),
if (ffi.connType == ConnType.defaultConn)
@@ -1012,14 +1051,14 @@ class _DisplayMenuState extends State<_DisplayMenu> {
return Column(children: [
...v.map((e) {
final isCustom = e.value == kRemoteViewStyleCustom;
final child = isCustom
? Text(translate('Scale custom'))
: e.child;
final child =
isCustom ? Text(translate('Scale custom')) : e.child;
// Whether the current selection is already custom
final bool isGroupCustomSelected =
e.groupValue == kRemoteViewStyleCustom;
// Keep menu open when switching INTO custom so the slider is visible immediately
final bool keepOpenForThisItem = isCustom && !isGroupCustomSelected;
final bool keepOpenForThisItem =
isCustom && !isGroupCustomSelected;
return RdoMenuButton<String>(
value: e.value,
groupValue: e.groupValue,
@@ -1038,7 +1077,8 @@ class _DisplayMenuState extends State<_DisplayMenu> {
}).toList(),
// Only show a divider when custom is NOT selected
if (!isCustomSelected) Divider(),
_customControlsIfCustomSelected(onChanged: (v) => customPercent.value = v),
_customControlsIfCustomSelected(
onChanged: (v) => customPercent.value = v),
]);
});
}
@@ -1053,12 +1093,14 @@ class _DisplayMenuState extends State<_DisplayMenu> {
duration: Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: isCustom ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) : SizedBox.shrink(),
child: isCustom
? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged)
: SizedBox.shrink(),
);
});
}
scrollStyle() {
scrollStyle(_IconSubmenuButtonState state, ColorScheme colorScheme) {
return futureBuilder(future: () async {
final viewStyle =
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
@@ -1066,16 +1108,34 @@ class _DisplayMenuState extends State<_DisplayMenu> {
viewStyle == kRemoteViewStyleCustom;
final scrollStyle =
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
return {'visible': visible, 'scrollStyle': scrollStyle};
final edgeScrollEdgeThickness = await bind
.sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId);
return {
'visible': visible,
'scrollStyle': scrollStyle,
'edgeScrollEdgeThickness': edgeScrollEdgeThickness,
};
}(), hasData: (data) {
final visible = data['visible'] as bool;
if (!visible) return Offstage();
final groupValue = data['scrollStyle'] as String;
onChange(String? value) async {
final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int;
onChangeScrollStyle(String? value) async {
if (value == null) return;
await bind.sessionSetScrollStyle(
sessionId: ffi.sessionId, value: value);
widget.ffi.canvasModel.updateScrollStyle();
state.setState(() {});
}
onChangeEdgeScrollEdgeThickness(double? value) async {
if (value == null) return;
final newThickness = value.round();
await bind.sessionSetEdgeScrollEdgeThickness(
sessionId: ffi.sessionId, value: newThickness);
widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness);
state.setState(() {});
}
return Obx(() => Column(children: [
@@ -1084,8 +1144,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
value: kRemoteScrollStyleAuto,
groupValue: groupValue,
onChanged: widget.ffi.canvasModel.imageOverflow.value
? (value) => onChange(value)
? (value) => onChangeScrollStyle(value)
: null,
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
ffi: widget.ffi,
),
RdoMenuButton<String>(
@@ -1093,10 +1154,30 @@ class _DisplayMenuState extends State<_DisplayMenu> {
value: kRemoteScrollStyleBar,
groupValue: groupValue,
onChanged: widget.ffi.canvasModel.imageOverflow.value
? (value) => onChange(value)
? (value) => onChangeScrollStyle(value)
: null,
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
ffi: widget.ffi,
),
if (!isWeb) ...[
RdoMenuButton<String>(
child: Text(translate('ScrollEdge')),
value: kRemoteScrollStyleEdge,
groupValue: groupValue,
closeOnActivate: false,
onChanged: widget.ffi.canvasModel.imageOverflow.value
? (value) => onChangeScrollStyle(value)
: null,
ffi: widget.ffi,
),
Offstage(
offstage: groupValue != kRemoteScrollStyleEdge,
child: EdgeThicknessControl(
value: edgeScrollEdgeThickness.toDouble(),
onChanged: onChangeEdgeScrollEdgeThickness,
colorScheme: colorScheme,
)),
],
Divider(),
]));
});
@@ -1183,132 +1264,21 @@ class _DisplayMenuState extends State<_DisplayMenu> {
class _CustomScaleMenuControls extends StatefulWidget {
final FFI ffi;
final ValueChanged<int>? onChanged;
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) : super(key: key);
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged})
: super(key: key);
@override
State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState();
State<_CustomScaleMenuControls> createState() =>
_CustomScaleMenuControlsState();
}
class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
late int _value;
late final Debouncer<int> _debouncerScale;
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
double _pos = 0.0;
// Piecewise mapping constants (moved to consts.dart)
static const int _minPercent = kScaleCustomMinPercent;
static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
static const int _maxPercent = kScaleCustomMaxPercent;
static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
// Clamp helper for local use
int _clamp(int v) => clampCustomScalePercent(v);
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
int _mapPosToPercent(double p) {
if (p <= 0.0) return _minPercent;
if (p >= 1.0) return _maxPercent;
if (p <= _pivotPos) {
final q = p / _pivotPos; // 0..1
final v = _minPercent + q * (_pivotPercent - _minPercent);
return _clamp(v.round());
} else {
final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1
final v = _pivotPercent + q * (_maxPercent - _pivotPercent);
return _clamp(v.round());
}
}
// Map percent [5,1000] → normalized position [0,1]
double _mapPercentToPos(int percent) {
final p = _clamp(percent);
if (p <= _pivotPercent) {
final q = (p - _minPercent) / (_pivotPercent - _minPercent);
return q * _pivotPos;
} else {
final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent);
return _pivotPos + q * (1.0 - _pivotPos);
}
}
// Snap normalized position to the pivot when close to it
double _snapNormalizedPos(double p) {
if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos;
if (p < 0.0) return 0.0;
if (p > 1.0) return 1.0;
return p;
}
class _CustomScaleMenuControlsState
extends CustomScaleControls<_CustomScaleMenuControls> {
@override
FFI get ffi => widget.ffi;
@override
void initState() {
super.initState();
_value = 100;
_debouncerScale = Debouncer<int>(
kDebounceCustomScaleDuration,
onChanged: (v) async {
await _apply(v);
},
initialValue: _value,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
if (mounted) {
setState(() {
_value = v;
_pos = _mapPercentToPos(v);
});
}
} catch (e, st) {
debugPrint('[CustomScale] Failed to get initial value: $e');
debugPrintStack(stackTrace: st);
}
});
}
Future<void> _apply(int v) async {
v = clampCustomScalePercent(v);
setState(() {
_value = v;
});
try {
await bind.sessionSetFlutterOption(
sessionId: widget.ffi.sessionId,
k: kCustomScalePercentKey,
v: v.toString());
final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId);
if (curStyle != kRemoteViewStyleCustom) {
await bind.sessionSetViewStyle(
sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom);
}
await widget.ffi.canvasModel.updateViewStyle();
if (isMobile) {
HapticFeedback.selectionClick();
}
widget.onChanged?.call(v);
} catch (e, st) {
debugPrint('[CustomScale] Apply failed: $e');
debugPrintStack(stackTrace: st);
}
}
void _nudge(int delta) {
final next = _clamp(_value + delta);
setState(() {
_value = next;
_pos = _mapPercentToPos(next);
});
widget.onChanged?.call(next);
_debouncerScale.value = next;
}
@override
void dispose() {
_debouncerScale.cancel();
super.dispose();
}
ValueChanged<int>? get onScaleChanged => widget.onChanged;
@override
Widget build(BuildContext context) {
@@ -1317,7 +1287,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
final sliderControl = Semantics(
label: translate('Custom scale slider'),
value: '$_value%',
value: '$scaleValue%',
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: colorScheme.primary,
@@ -1325,34 +1295,24 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
overlayColor: colorScheme.primary.withOpacity(0.1),
showValueIndicator: ShowValueIndicator.never,
thumbShape: _RectValueThumbShape(
min: _minPercent.toDouble(),
max: _maxPercent.toDouble(),
min: CustomScaleControls.minPercent.toDouble(),
max: CustomScaleControls.maxPercent.toDouble(),
width: 52,
height: 24,
radius: 4,
// Display the mapped percent for the current normalized value
displayValueForNormalized: (t) => _mapPosToPercent(t),
displayValueForNormalized: (t) => mapPosToPercent(t),
),
),
child: Slider(
value: _pos,
value: scalePos,
min: 0.0,
max: 1.0,
// Use a wide range of divisions (calculated as (_maxPercent - _minPercent)) to provide ~1% precision increments.
// Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.minPercent)) to provide ~1% precision increments.
// This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges.
divisions: (_maxPercent - _minPercent).round(),
onChanged: (v) {
final snapped = _snapNormalizedPos(v);
final next = _mapPosToPercent(snapped);
if (next != _value || snapped != _pos) {
setState(() {
_pos = snapped;
_value = next;
});
widget.onChanged?.call(next);
_debouncerScale.value = next;
}
},
divisions:
(CustomScaleControls.maxPercent - CustomScaleControls.minPercent)
.round(),
onChanged: onSliderChanged,
),
),
);
@@ -1368,7 +1328,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
padding: EdgeInsets.all(1),
constraints: smallBtnConstraints,
icon: const Icon(Icons.remove),
onPressed: () => _nudge(-1),
onPressed: () => nudgeScale(-1),
),
),
Expanded(child: sliderControl),
@@ -1379,7 +1339,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
padding: EdgeInsets.all(1),
constraints: smallBtnConstraints,
icon: const Icon(Icons.add),
onPressed: () => _nudge(1),
onPressed: () => nudgeScale(1),
),
),
]),
@@ -1397,6 +1357,7 @@ class _RectValueThumbShape extends SliderComponentShape {
final double width;
final double height;
final double radius;
final String unit;
// Optional mapper to compute display value from normalized position [0,1]
// If null, falls back to linear interpolation between min and max.
final int Function(double normalized)? displayValueForNormalized;
@@ -1408,6 +1369,7 @@ class _RectValueThumbShape extends SliderComponentShape {
required this.height,
required this.radius,
this.displayValueForNormalized,
this.unit = '%',
});
@override
@@ -1448,12 +1410,12 @@ class _RectValueThumbShape extends SliderComponentShape {
final Paint paint = Paint()..color = fillColor;
canvas.drawRRect(rrect, paint);
// Compute displayed percent from normalized slider value.
final int percent = displayValueForNormalized != null
// Compute displayed value from normalized slider value.
final int displayValue = displayValueForNormalized != null
? displayValueForNormalized!(value)
: (min + value * (max - min)).round();
final TextSpan span = TextSpan(
text: '$percent%',
text: '$displayValue$unit',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
@@ -1466,7 +1428,8 @@ class _RectValueThumbShape extends SliderComponentShape {
textDirection: textDirection,
);
tp.layout(maxWidth: width - 4);
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
tp.paint(
canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
}
}
@@ -1802,17 +1765,27 @@ class _KeyboardMenu extends StatelessWidget {
Widget build(BuildContext context) {
var ffiModel = Provider.of<FfiModel>(context);
if (!ffiModel.keyboard) return Offstage();
toolbarToggles() => toolbarKeyboardToggles(ffi)
.map((e) => CkbMenuButton(
value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi))
.toList();
toolbarToggles() {
final toggles = toolbarKeyboardToggles(ffi)
.map((e) => CkbMenuButton(
value: e.value,
onChanged: e.onChanged,
child: e.child,
ffi: ffi) as Widget)
.toList();
if (toggles.isNotEmpty) {
toggles.add(Divider());
}
return toggles;
}
return _IconSubmenuButton(
tooltip: 'Keyboard Settings',
svg: "assets/keyboard.svg",
svg: "assets/keyboard_mouse.svg",
ffi: ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
menuChildrenGetter: () => [
menuChildrenGetter: (_) => [
keyboardMode(),
localKeyboardType(),
inputSource(),
@@ -2077,7 +2050,7 @@ class _ChatMenuState extends State<_ChatMenu> {
ffi: widget.ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
menuChildrenGetter: () => [textChat(), voiceCall()]);
menuChildrenGetter: (_) => [textChat(), voiceCall()]);
}
}
@@ -2133,7 +2106,7 @@ class _VoiceCallMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
menuChildrenGetter() {
menuChildrenGetter(_IconSubmenuButtonState state) {
final audioInput = AudioInput(
builder: (devices, currentDevice, setDevice) {
return Column(
@@ -2239,7 +2212,12 @@ class _CloseMenu extends StatelessWidget {
return _IconMenuButton(
assetName: 'assets/close.svg',
tooltip: 'Close',
onPressed: () => closeConnection(id: id),
onPressed: () async {
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
return;
}
closeConnection(id: id);
},
color: _ToolbarTheme.redColor,
hoverColor: _ToolbarTheme.hoverRedColor,
);
@@ -2333,7 +2311,7 @@ class _IconSubmenuButton extends StatefulWidget {
final Widget? icon;
final Color color;
final Color hoverColor;
final List<Widget> Function() menuChildrenGetter;
final List<Widget> Function(_IconSubmenuButtonState state) menuChildrenGetter;
final MenuStyle? menuStyle;
final FFI? ffi;
final double? width;
@@ -2358,6 +2336,11 @@ class _IconSubmenuButton extends StatefulWidget {
class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
bool hover = false;
@override // discard @protected
void setState(VoidCallback fn) {
super.setState(fn);
}
@override
Widget build(BuildContext context) {
assert(widget.svg != null || widget.icon != null);
@@ -2390,7 +2373,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
),
child: icon))),
menuChildren: widget
.menuChildrenGetter()
.menuChildrenGetter(this)
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
.toList()));
return MenuBar(children: [
@@ -2555,7 +2538,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
double left = 0.0;
double right = 1.0;
RxBool get show => widget.toolbarState.show;
RxBool get collapse => widget.toolbarState.collapse;
@override
initState() {
@@ -2678,20 +2661,20 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
)),
buttonWrapper(
() => setState(() {
widget.toolbarState.switchShow(widget.sessionId);
widget.toolbarState.switchCollapse(widget.sessionId);
}),
Obx((() => Tooltip(
message:
translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
message: translate(
collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'),
child: Icon(
show.isTrue ? Icons.expand_less : Icons.expand_more,
collapse.isFalse ? Icons.expand_less : Icons.expand_more,
size: iconSize,
),
))),
),
if (isWebDesktop)
Obx(() {
if (show.isTrue) {
if (collapse.isFalse) {
return Offstage();
} else {
return buttonWrapper(
@@ -2753,3 +2736,56 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) {
),
);
}
class EdgeThicknessControl extends StatelessWidget {
final double value;
final ValueChanged<double>? onChanged;
final ColorScheme? colorScheme;
const EdgeThicknessControl({
Key? key,
required this.value,
this.onChanged,
this.colorScheme,
}) : super(key: key);
static const double kMin = 20;
static const double kMax = 150;
@override
Widget build(BuildContext context) {
final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme;
final slider = SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: colorScheme.primary,
thumbColor: colorScheme.primary,
overlayColor: colorScheme.primary.withOpacity(0.1),
showValueIndicator: ShowValueIndicator.never,
thumbShape: _RectValueThumbShape(
min: EdgeThicknessControl.kMin,
max: EdgeThicknessControl.kMax,
width: 52,
height: 24,
radius: 4,
unit: 'px',
),
),
child: Semantics(
value: value.toInt().toString(),
child: Slider(
value: value,
min: EdgeThicknessControl.kMin,
max: EdgeThicknessControl.kMax,
divisions:
(EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(),
semanticFormatterCallback: (double newValue) =>
"${newValue.round()}px",
onChanged: onChanged,
),
),
);
return slider;
}
}

View File

@@ -593,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
Widget _buildBar() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: GestureDetector(

View File

@@ -12,7 +12,11 @@ import '../../common/widgets/dialog.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
{Key? key,
required this.id,
this.password,
this.isSharedPassword,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
@@ -113,8 +117,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
leading: Row(children: [
IconButton(
icon: Icon(Icons.close),
onPressed: () =>
clientClose(gFFI.sessionId, gFFI.dialogManager)),
onPressed: () => clientClose(gFFI.sessionId, gFFI)),
]),
centerTitle: true,
title: ToggleSwitch(
@@ -352,15 +355,21 @@ class _FileManagerPageState extends State<FileManagerPage> {
return Offstage();
}
switch (jobTable.last.state) {
// Find the first job that is in progress (the one actually transferring data)
// Rust backend processes jobs sequentially, so the first inProgress job is the active one
final activeJob = jobTable
.firstWhereOrNull((job) => job.state == JobState.inProgress) ??
jobTable.last;
switch (activeJob.state) {
case JobState.inProgress:
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
"${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s",
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
onCanceled: () {
model.jobController.cancelJob(jobTable.last.id);
model.jobController.cancelJob(activeJob.id);
jobTable.clear();
},
);
@@ -368,7 +377,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
return BottomSheetBody(
leading: Icon(Icons.check),
title: "${translate("Successful")}!",
text: jobTable.last.display(),
text: activeJob.display(),
onCanceled: () => jobTable.clear(),
);
case JobState.error:
@@ -591,67 +600,67 @@ class _FileManagerViewState extends State<FileManagerView> {
Widget headTools() => Container(
child: Row(
children: [
Expanded(child: Obx(() {
final home = controller.options.value.home;
final isWindows = controller.options.value.isWindows;
return BreadCrumb(
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
() => controller.goToHomeDirectory(), (list) {
var path = "";
if (home.startsWith(list[0])) {
// absolute path
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
} else {
path += home;
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
}
controller.openDirectory(path);
}),
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
);
})),
Row(
children: [
Expanded(child: Obx(() {
final home = controller.options.value.home;
final isWindows = controller.options.value.isWindows;
return BreadCrumb(
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
() => controller.goToHomeDirectory(), (list) {
var path = "";
if (home.startsWith(list[0])) {
// absolute path
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
IconButton(
icon: Icon(Icons.arrow_back),
onPressed: controller.goBack,
),
IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: controller.goToParentDirectory,
),
PopupMenuButton<SortBy>(
tooltip: "",
icon: Icon(Icons.sort),
itemBuilder: (context) {
return SortBy.values
.map((e) => PopupMenuItem(
child: Text(translate(e.toString())),
value: e,
))
.toList();
},
onSelected: (sortBy) {
// If selecting the same sort option, flip the order
// If selecting a different sort option, use ascending order
if (controller.sortBy.value == sortBy) {
ascending.value = !controller.sortAscending;
} else {
path += home;
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
ascending.value = true;
}
controller.openDirectory(path);
controller.changeSortStyle(sortBy,
ascending: ascending.value);
}),
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
);
})),
Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back),
onPressed: controller.goBack,
),
IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: controller.goToParentDirectory,
),
PopupMenuButton<SortBy>(
tooltip: "",
icon: Icon(Icons.sort),
itemBuilder: (context) {
return SortBy.values
.map((e) => PopupMenuItem(
child: Text(translate(e.toString())),
value: e,
))
.toList();
},
onSelected: (sortBy) {
// If selecting the same sort option, flip the order
// If selecting a different sort option, use ascending order
if (controller.sortBy.value == sortBy) {
ascending.value = !controller.sortAscending;
} else {
ascending.value = true;
}
controller.changeSortStyle(sortBy, ascending: ascending.value);
}
),
],
)
],
));
)
],
));
Widget listTail() => Obx(() => Container(
height: 100,

View File

@@ -25,6 +25,7 @@ import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../utils/image.dart';
import '../widgets/dialog.dart';
import '../widgets/custom_scale_widget.dart';
final initText = '1' * 1024;
@@ -365,7 +366,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
@@ -483,7 +484,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
},
),
IconButton(
@@ -568,7 +569,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
bool get showCursorPaint =>
!gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
!gFFI.ffiModel.isPeerAndroid &&
!gFFI.canvasModel.cursorEmbedded &&
!gFFI.inputModel.relativeMouseMode.value;
Widget getBodyForMobile() {
final keyboardIsVisible = keyboardVisibilityController.isVisible;
@@ -576,7 +579,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
color: MyTheme.canvasColor,
child: Stack(children: () {
final paints = [
ImagePaint(),
ImagePaint(ffiModel: gFFI.ffiModel),
Positioned(
top: 10,
right: 10,
@@ -634,7 +637,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
Widget getBodyForDesktopWithListener() {
final ffiModel = Provider.of<FfiModel>(context);
var paints = <Widget>[ImagePaint()];
var paints = <Widget>[ImagePaint(ffiModel: ffiModel)];
if (showCursorPaint) {
final cursor = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor');
@@ -807,6 +810,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
},
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
inputModel: gFFI.inputModel,
)));
}
@@ -1054,11 +1058,20 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
}
class ImagePaint extends StatelessWidget {
final FfiModel ffiModel;
ImagePaint({Key? key, required this.ffiModel}) : super(key: key);
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
final c = Provider.of<CanvasModel>(context);
var s = c.scale;
if (ffiModel.isPeerLinux) {
final displays = ffiModel.pi.getCurDisplays();
if (displays.isNotEmpty) {
s = s / displays[0].scale;
}
}
final adjust = c.getAdjustY();
return CustomPaint(
painter: ImagePainter(
@@ -1129,6 +1142,14 @@ void showOptions(
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
final numColorSelected = Colors.white;
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
// We can't use `Theme.of(context).primaryColor` here, the color is:
// - light theme: 0xff2196f3 (Colors.blue)
// - dark theme: 0xff212121 (the canvas color?)
final numBgSelected =
Theme.of(context).colorScheme.primary.withOpacity(0.6);
for (var i = 0; i < pi.displays.length; ++i) {
children.add(InkWell(
onTap: () {
@@ -1142,13 +1163,12 @@ void showOptions(
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),
color: i == cur ? numBgSelected : null),
child: Center(
child: Text((i + 1).toString(),
style: TextStyle(
color: i == cur ? Colors.white : Colors.black87,
color:
i == cur ? numColorSelected : numColorUnselected,
fontWeight: FontWeight.bold))))));
}
displays.add(Padding(
@@ -1201,6 +1221,10 @@ void showOptions(
if (v != null) viewStyle.value = v;
}
: null)),
// Show custom scale controls when custom view style is selected
Obx(() => viewStyle.value == kRemoteViewStyleCustom
? MobileCustomScaleControls(ffi: gFFI)
: const SizedBox.shrink()),
const Divider(color: MyTheme.border),
for (var e in imageQualityRadios)
Obx(() => getRadio<String>(

View File

@@ -156,7 +156,7 @@ class _ScanPageState extends State<ScanPage> {
try {
final sc = ServerConfig.decode(data.substring(7));
Timer(Duration(milliseconds: 60), () {
showServerSettingsWithValue(sc, gFFI.dialogManager);
showServerSettingsWithValue(sc, gFFI.dialogManager, null);
});
} catch (e) {
showToast('Invalid QR code');

View File

@@ -61,12 +61,13 @@ class _DropDownAction extends StatelessWidget {
final isAllowNumericOneTimePassword =
gFFI.serverModel.allowNumericOneTimePassword;
return [
PopupMenuItem(
enabled: gFFI.serverModel.connectStatus > 0,
value: "changeID",
child: Text(translate("Change ID")),
),
const PopupMenuDivider(),
if (!isChangeIdDisabled())
PopupMenuItem(
enabled: gFFI.serverModel.connectStatus > 0,
value: "changeID",
child: Text(translate("Change ID")),
),
if (!isChangeIdDisabled()) const PopupMenuDivider(),
PopupMenuItem(
value: 'AcceptSessionsViaPassword',
child: listTile(
@@ -87,7 +88,8 @@ class _DropDownAction extends StatelessWidget {
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption &&
verificationMethod != kUseTemporaryPassword)
verificationMethod != kUseTemporaryPassword &&
!isChangePermanentPasswordDisabled())
PopupMenuItem(
value: "setPermanentPassword",
child: Text(translate("Set permanent password")),
@@ -149,6 +151,10 @@ class _DropDownAction extends StatelessWidget {
if (value == kUsePermanentPassword &&
(await bind.mainGetPermanentPassword()).isEmpty) {
if (isChangePermanentPasswordDisabled()) {
callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
@@ -648,9 +654,8 @@ class ConnectionManager extends StatelessWidget {
return Column(
children: serverModel.clients
.map((client) => PaddingCard(
title: translate(client.isFileTransfer
? "Transfer file"
: "Share screen"),
title: translate(
client.isFileTransfer ? "Transfer file" : "Share screen"),
titleIcon: client.isFileTransfer
? Icon(Icons.folder_outlined)
: Icon(Icons.mobile_screen_share),

View File

@@ -71,6 +71,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _ignoreBatteryOpt = false;
var _enableStartOnBoot = false;
var _checkUpdateOnStartup = false;
var _showTerminalExtraKeys = false;
var _floatingWindowDisabled = false;
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
var _enableAbr = false;
@@ -94,7 +95,11 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _hideWebSocket = false;
var _enableTrustedDevices = false;
var _enableUdpPunch = false;
var _allowInsecureTlsFallback = false;
var _disableUdp = false;
var _enableIpv6Punch = false;
var _isUsingPublicServer = false;
var _allowAskForNoteAtEndOfConnection = false;
_SettingsState() {
_enableAbr = option2bool(
@@ -109,6 +114,9 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
_allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket);
_allowInsecureTlsFallback =
mainGetBoolOptionSync(kOptionAllowInsecureTLSFallback);
_disableUdp = bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
_autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing,
@@ -130,6 +138,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
_showTerminalExtraKeys =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
}
@override
@@ -200,6 +212,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
update = true;
_buildDate = buildDate;
}
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
if (_isUsingPublicServer != isUsingPublicServer) {
update = true;
_isUsingPublicServer = isUsingPublicServer;
}
if (update) {
setState(() {});
}
@@ -586,6 +605,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
);
}
enhancementsTiles.add(
SettingsTile.switchTile(
initialValue: _showTerminalExtraKeys,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(translate('Show terminal extra keys')),
]),
onToggle: (bool v) async {
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
setState(() {
_showTerminalExtraKeys = newValue;
});
},
),
);
onFloatingWindowChanged(bool toValue) async {
if (toValue) {
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
@@ -667,9 +703,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
title: Text(translate('ID/Relay Server')),
leading: Icon(Icons.cloud),
onPressed: (context) {
showServerSettings(gFFI.dialogManager);
showServerSettings(gFFI.dialogManager, (callback) async {
_isUsingPublicServer = await bind.mainIsUsingPublicServer();
setState(callback);
});
}),
if (!isIOS && !_hideNetwork && !_hideProxy)
if (!_hideNetwork && !_hideProxy)
SettingsTile(
title: Text(translate('Socks5/Http(s) Proxy')),
leading: Icon(Icons.network_ping),
@@ -691,6 +730,38 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
});
},
),
if (!_isUsingPublicServer)
SettingsTile.switchTile(
title: Text(translate('Allow insecure TLS fallback')),
initialValue: _allowInsecureTlsFallback,
onToggle: isOptionFixed(kOptionAllowInsecureTLSFallback)
? null
: (v) async {
await mainSetBoolOption(
kOptionAllowInsecureTLSFallback, v);
final newValue = mainGetBoolOptionSync(
kOptionAllowInsecureTLSFallback);
setState(() {
_allowInsecureTlsFallback = newValue;
});
},
),
if (isAndroid && !outgoingOnly && !_isUsingPublicServer)
SettingsTile.switchTile(
title: Text(translate('Disable UDP')),
initialValue: _disableUdp,
onToggle: isOptionFixed(kOptionDisableUdp)
? null
: (v) async {
await bind.mainSetOption(
key: kOptionDisableUdp, value: v ? 'Y' : 'N');
final newValue =
bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
setState(() {
_disableUdp = newValue;
});
},
),
if (!incomingOnly)
SettingsTile.switchTile(
title: Text(translate('Enable UDP hole punching')),
@@ -734,7 +805,25 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) {
showThemeSettings(gFFI.dialogManager);
},
)
),
if (!bind.isDisableAccount())
SettingsTile.switchTile(
title: Text(translate('note-at-conn-end-tip')),
initialValue: _allowAskForNoteAtEndOfConnection,
onToggle: (v) async {
if (v && !gFFI.userModel.isLogin) {
final res = await loginDialog();
if (res != true) return;
}
await mainSetLocalBoolOption(
kOptionAllowAskForNoteAtEndOfConnection, v);
final newValue = mainGetLocalBoolOptionSync(
kOptionAllowAskForNoteAtEndOfConnection);
setState(() {
_allowAskForNoteAtEndOfConnection = newValue;
});
},
)
]),
if (isAndroid)
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [

View File

@@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
@@ -28,9 +32,15 @@ class TerminalPage extends StatefulWidget {
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
double _sysKeyboardHeight = 0;
Timer? _keyboardDebounce;
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -38,9 +48,12 @@ class _TerminalPageState extends State<TerminalPage>
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
: 'monospace';
SessionID get sessionId => _ffi.sessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
@@ -59,13 +72,22 @@ class _TerminalPageState extends State<TerminalPage>
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
_showTerminalExtraKeys = mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
if (_showTerminalExtraKeys) {
_updateKeyboardHeight();
}
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@@ -75,40 +97,221 @@ class _TerminalPageState extends State<TerminalPage>
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
_keyboardDebounce?.cancel();
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
setState(() {
_sysKeyboardHeight = bottomInset;
});
});
}
void _updateKeyboardHeight() {
if (_keyboardKey.currentContext != null) {
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
_keyboardHeight = renderBox.size.height;
}
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
@override
Widget build(BuildContext context) {
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false; // Prevent default back behavior
},
child: buildBody(),
);
}
Widget buildBody() {
return Scaffold(
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
body: Stack(
children: [
Positioned.fill(
child: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
),
),
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
],
),
);
}
Widget _buildFloatingKeyboard() {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 0,
right: 0,
bottom: _sysKeyboardHeight,
child: Container(
key: _keyboardKey,
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
],
),
],
),
),
);
}
Widget _buildKeyButton(String label) {
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
switch (key) {
case 'Esc':
send = '\x1B';
break;
case 'Tab':
send = '\t';
break;
case 'Ctrl+C':
send = '\x03';
break;
case '':
send = '\x1B[A';
break;
case '':
send = '\x1B[B';
break;
case '':
send = '\x1B[C';
break;
case '':
send = '\x1B[D';
break;
case 'Home':
send = '\x1B[H';
break;
case 'End':
send = '\x1B[F';
break;
case 'PgUp':
send = '\x1B[5~';
break;
case 'PgDn':
send = '\x1B[6~';
break;
default:
send = key;
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {

View File

@@ -197,7 +197,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
@@ -310,7 +310,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
},
),
IconButton(
@@ -590,6 +590,14 @@ void showOptions(
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
final numColorSelected = Colors.white;
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
// We can't use `Theme.of(context).primaryColor` here, the color is:
// - light theme: 0xff2196f3 (Colors.blue)
// - dark theme: 0xff212121 (the canvas color?)
final numBgSelected =
Theme.of(context).colorScheme.primary.withOpacity(0.6);
for (var i = 0; i < pi.displays.length; ++i) {
children.add(InkWell(
onTap: () {
@@ -603,13 +611,12 @@ void showOptions(
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),
color: i == cur ? numBgSelected : null),
child: Center(
child: Text((i + 1).toString(),
style: TextStyle(
color: i == cur ? Colors.white : Colors.black87,
color:
i == cur ? numColorSelected : numColorUnselected,
fontWeight: FontWeight.bold))))));
}
displays.add(Padding(

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
class MobileCustomScaleControls extends StatefulWidget {
final FFI ffi;
final ValueChanged<int>? onChanged;
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
@override
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
}
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
@override
FFI get ffi => widget.ffi;
@override
ValueChanged<int>? get onScaleChanged => widget.onChanged;
@override
Widget build(BuildContext context) {
// Smaller button size for mobile
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
final sliderControl = Slider(
value: scalePos,
min: 0.0,
max: 1.0,
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
label: '$scaleValue%',
onChanged: onSliderChanged,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${translate("Scale custom")}: $scaleValue%',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.remove),
tooltip: translate('Decrease'),
onPressed: () => nudgeScale(-1),
),
Expanded(child: sliderControl),
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.add),
tooltip: translate('Increase'),
onPressed: () => nudgeScale(1),
),
],
),
],
),
);
}
}

View File

@@ -147,18 +147,22 @@ void setTemporaryPasswordLengthDialog(
}, backDismiss: true, clickMaskDismiss: true);
}
void showServerSettings(OverlayDialogManager dialogManager) async {
void showServerSettings(OverlayDialogManager dialogManager,
void Function(VoidCallback) setState) async {
Map<String, dynamic> options = {};
try {
options = jsonDecode(await bind.mainGetOptions());
} catch (e) {
print("Invalid server config: $e");
}
showServerSettingsWithValue(ServerConfig.fromOptions(options), dialogManager);
showServerSettingsWithValue(
ServerConfig.fromOptions(options), dialogManager, setState);
}
void showServerSettingsWithValue(
ServerConfig serverConfig, OverlayDialogManager dialogManager) async {
ServerConfig serverConfig,
OverlayDialogManager dialogManager,
void Function(VoidCallback)? upSetState) async {
var isInProgress = false;
final idCtrl = TextEditingController(text: serverConfig.idServer);
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
@@ -288,6 +292,7 @@ void showServerSettingsWithValue(
if (await submit()) {
close();
showToast(translate('Successful'));
upSetState?.call(() {});
} else {
showToast(translate('Failed'));
}

View File

@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(cursorModel: _cursorModel),
VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
@@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter {
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// Virtual joystick sends the absolute movement for now.
// Maybe we need to change it to relative movement in the future.
// Virtual joystick can send either absolute movement (via updatePan)
// or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({super.key, required this.cursorModel});
const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override
State<VirtualJoystick> createState() => _VirtualJoystickState();
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
final double _moveStep = 3.0;
final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
@@ -701,6 +714,9 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
Size? _lastScreenSize;
bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override
void initState() {
super.initState();
@@ -746,6 +762,18 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
_sendMovement(initialDelta);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
@@ -784,10 +812,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
widget.cursorModel.updatePan(
_offsetToPanDelta(_offset) * _moveStep * _speed,
Offset.zero,
false);
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
}
});
});

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
@@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget {
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
required this.virtualMouseMode})
required this.virtualMouseMode,
this.inputModel})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override
State<StatefulWidget> createState() =>
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = _touchMode ? 1 : 0;
}
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -103,6 +115,8 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
}
});
},
@@ -117,12 +131,18 @@ class _GestureHelpState extends State<GestureHelp> {
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
@@ -196,6 +216,10 @@ class _GestureHelpState extends State<GestureHelp> {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
),
@@ -203,6 +227,10 @@ class _GestureHelpState extends State<GestureHelp> {
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
child: Text(
@@ -211,6 +239,39 @@ class _GestureHelpState extends State<GestureHelp> {
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),

View File

@@ -202,6 +202,7 @@ class AbModel {
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
if (resp.statusCode == 404) {
debugPrint("HTTP 404, api server doesn't support shared address book");
@@ -228,6 +229,7 @@ class AbModel {
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
if (resp.statusCode == 404) {
debugPrint("HTTP 404, current api server is legacy mode");
@@ -269,6 +271,7 @@ class AbModel {
});
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
Map<String, dynamic> json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
@@ -1012,16 +1015,8 @@ class LegacyAb extends BaseAb {
var authHeaders = getHttpHeaders();
authHeaders['Content-Type'] = "application/json";
final body = jsonEncode({"data": jsonEncode(_serialize())});
http.Response resp;
// support compression
if (licensedDevices > 0 && body.length > 1024) {
authHeaders['Content-Encoding'] = "gzip";
resp = await http.post(Uri.parse(api),
headers: authHeaders, body: GZipCodec().encode(utf8.encode(body)));
} else {
resp =
await http.post(Uri.parse(api), headers: authHeaders, body: body);
}
http.Response resp =
await http.post(Uri.parse(api), headers: authHeaders, body: body);
if (resp.statusCode == 200 &&
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
ret = true;
@@ -1406,6 +1401,7 @@ class Ab extends BaseAb {
});
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
Map<String, dynamic> json =
@@ -1463,6 +1459,7 @@ class Ab extends BaseAb {
);
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
List<dynamic> json =
@@ -1977,3 +1974,8 @@ String _jsonDecodeActionResp(http.Response resp) {
}
return errMsg;
}
// https://github.com/seanmonstar/reqwest/issues/838
void _setEmptyBody(Map<String, String> headers) {
headers['Content-Length'] = '0';
}

View File

@@ -275,7 +275,7 @@ class TransferJobSerdeData {
: this(
connId: d['connId'] ?? 0,
id: int.tryParse(d['id'].toString()) ?? 0,
path: d['path'] ?? '',
path: d['dataSource'] ?? '',
isRemote: d['isRemote'] ?? false,
totalSize: d['totalSize'] ?? 0,
finishedSize: d['finishedSize'] ?? 0,

View File

@@ -113,6 +113,34 @@ class FileModel {
fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']);
}
// This method fixes a deadlock that occurred when the previous code directly
// called jobController.jobError(evt) in the job_error event handler.
//
// The problem with directly calling jobController.jobError():
// 1. fetchDirectoryRecursiveToRemove(jobID) registers readRecursiveTasks[jobID]
// and waits for completion
// 2. If the remote has no permission (or some other errors), it returns a FileTransferError
// 3. The error triggers job_error event, which called jobController.jobError()
// 4. jobController.jobError() calls getJob(jobID) to find the job in jobTable
// 5. But addDeleteDirJob() is called AFTER fetchDirectoryRecursiveToRemove(),
// so the job doesn't exist yet in jobTable
// 6. Result: jobController.jobError() does nothing useful, and
// readRecursiveTasks[jobID] never completes, causing a 2s timeout
//
// Solution: Before calling jobController.jobError(), we first check if there's
// a pending readRecursiveTasks with this ID and complete it with the error.
void handleJobError(Map<String, dynamic> evt) {
final id = int.tryParse(evt['id']?.toString() ?? '');
if (id != null) {
final err = evt['err']?.toString() ?? 'Unknown error';
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
}
// Always call jobController.jobError(evt) to ensure all error events are processed,
// even if the event does not have a valid job ID. This allows for generic error handling
// or logging of unexpected errors.
jobController.jobError(evt);
}
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
evtLoop.pushEvent(
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
@@ -591,8 +619,21 @@ class FileController {
} else if (item.isDirectory) {
title = translate("Not an empty directory");
dialogManager?.showLoading(translate("Waiting"));
final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
jobID, item.path, items.isLocal, true);
final FileDirectory fd;
try {
fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
jobID, item.path, items.isLocal, true);
} catch (e) {
dialogManager?.dismissAll();
final dm = dialogManager;
if (dm != null) {
msgBox(sessionId, 'custom-error-nook-nocancel-hasclose',
translate("Error"), e.toString(), '', dm);
} else {
debugPrint("removeAction error msgbox failed: $e");
}
return;
}
if (fd.path.isEmpty) {
fd.path = item.path;
}
@@ -606,7 +647,7 @@ class FileController {
item.name,
false);
if (confirm == true) {
sendRemoveEmptyDir(
await sendRemoveEmptyDir(
item.path,
0,
deleteJobId,
@@ -647,7 +688,7 @@ class FileController {
// handle remove res;
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i, deleteJobId);
await sendRemoveEmptyDir(item.path, i, deleteJobId);
}
} else {
jobController.updateJobStatus(deleteJobId,
@@ -660,7 +701,7 @@ class FileController {
final res = await jobController.jobResultListener.start();
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i, deleteJobId);
await sendRemoveEmptyDir(item.path, i, deleteJobId);
}
}
} else {
@@ -755,9 +796,9 @@ class FileController {
fileNum: fileNum);
}
void sendRemoveEmptyDir(String path, int fileNum, int actId) {
Future<void> sendRemoveEmptyDir(String path, int fileNum, int actId) async {
history.removeWhere((element) => element.contains(path));
bind.sessionRemoveAllEmptyDirs(
await bind.sessionRemoveAllEmptyDirs(
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
}
@@ -1275,6 +1316,15 @@ class FileFetcher {
}
}
// Complete a pending recursive read task with an error.
// See FileModel.handleJobError() for why this is necessary.
void tryCompleteRecursiveTaskWithError(int id, String error) {
final completer = readRecursiveTasks.remove(id);
if (completer != null && !completer.isCompleted) {
completer.completeError(error);
}
}
Future<List<FileDirectory>> readEmptyDirs(
String path, bool isLocal, bool showHidden) async {
try {
@@ -1438,6 +1488,10 @@ class JobProgress {
var err = "";
int lastTransferredSize = 0;
double get percent =>
totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0;
String get percentText => '${(percent * 100).toStringAsFixed(0)}%';
clear() {
type = JobType.none;
state = JobState.none;

View File

@@ -14,6 +14,8 @@ import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'relative_mouse_model.dart';
import '../common.dart';
import '../consts.dart';
@@ -42,8 +44,7 @@ class CanvasCoords {
'scale': scale,
'scrollX': scrollX,
'scrollY': scrollY,
'scrollStyle':
scrollStyle == ScrollStyle.scrollauto ? 'scrollauto' : 'scrollbar',
'scrollStyle': scrollStyle.toJson(),
'size': {
'w': size.width,
'h': size.height,
@@ -58,9 +59,7 @@ class CanvasCoords {
model.scale = json['scale'];
model.scrollX = json['scrollX'];
model.scrollY = json['scrollY'];
model.scrollStyle = json['scrollStyle'] == 'scrollauto'
? ScrollStyle.scrollauto
: ScrollStyle.scrollbar;
model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
model.size = Size(json['size']['w'], json['size']['h']);
return model;
}
@@ -352,15 +351,28 @@ class InputModel {
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
var _trackpadScrollUnsent = Offset.zero;
// Mobile relative mouse delta accumulators (for slow/fine movements).
double _mobileDeltaRemainderX = 0.0;
double _mobileDeltaRemainderY = 0.0;
var _lastScale = 1.0;
bool _pointerMovedAfterEnter = false;
bool _pointerInsideImage = false;
// mouse
final isPhysicalMouse = false.obs;
int _lastButtons = 0;
Offset lastMousePos = Offset.zero;
// Relative mouse mode (for games/3D apps).
final relativeMouseMode = false.obs;
late final RelativeMouseModel _relativeMouse;
// Callback to cancel external throttle timer when relative mouse mode is disabled.
VoidCallback? onRelativeMouseModeDisabled;
// Disposer for the relativeMouseMode observer (to prevent memory leaks).
Worker? _relativeMouseModeDisposer;
bool _queryOtherWindowCoords = false;
Rect? _windowRect;
List<RemoteWindowCoords> _remoteWindowCoords = [];
@@ -370,14 +382,40 @@ class InputModel {
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
int get trackpadSpeed => _trackpadSpeed;
bool get useEdgeScroll =>
parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
/// Check if the connected server supports relative mouse mode.
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
InputModel(this.parent) {
sessionId = parent.target!.sessionId;
_relativeMouse = RelativeMouseModel(
sessionId: sessionId,
enabled: relativeMouseMode,
keyboardPerm: () => keyboardPerm,
isViewCamera: () => isViewCamera,
peerVersion: () => peerVersion,
peerPlatform: () => peerPlatform,
modify: (msg) => modify(msg),
getPointerInsideImage: () => _pointerInsideImage,
setPointerInsideImage: (inside) => _pointerInsideImage = inside,
);
_relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call();
// Sync relative mouse mode state to global state for UI components (e.g., tab bar hint).
_relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) {
final peerId = id;
if (peerId.isNotEmpty) {
stateGlobal.relativeMouseModeState[peerId] = value;
}
});
}
// This function must be called after the peer info is received.
@@ -508,6 +546,10 @@ class InputModel {
}
}
if (_relativeMouse.handleRawKeyEvent(e)) {
return KeyEventResult.handled;
}
final key = e.logicalKey;
if (e is RawKeyDownEvent) {
if (!e.repeat) {
@@ -570,6 +612,16 @@ class InputModel {
}
}
if (_relativeMouse.handleKeyEvent(
e,
ctrlPressed: ctrl,
shiftPressed: shift,
altPressed: alt,
commandPressed: command,
)) {
return KeyEventResult.handled;
}
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
@@ -855,11 +907,13 @@ class InputModel {
toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
_pointerInsideImage = enter;
// Fix status
if (!enter) {
resetModifiers();
}
_relativeMouse.onEnterOrLeaveImage(enter);
_flingTimer?.cancel();
if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
@@ -880,15 +934,134 @@ class InputModel {
msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
}
/// Send relative mouse movement for mobile clients (virtual joystick).
/// This method is for touch-based controls that want to send delta values.
/// Uses the 'move_relative' type which bypasses absolute position tracking.
///
/// Accumulates fractional deltas to avoid losing slow/fine movements.
/// Only sends events when relative mouse mode is enabled and supported.
Future<void> sendMobileRelativeMouseMove(double dx, double dy) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
// Only send relative mouse events when relative mode is enabled and supported.
if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
_mobileDeltaRemainderX += dx;
_mobileDeltaRemainderY += dy;
final x = _mobileDeltaRemainderX.truncate();
final y = _mobileDeltaRemainderY.truncate();
_mobileDeltaRemainderX -= x;
_mobileDeltaRemainderY -= y;
if (x == 0 && y == 0) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({
'type': 'move_relative',
'x': '$x',
'y': '$y',
})));
}
/// Update the pointer lock center position based on current window frame.
Future<void> updatePointerLockCenter({Offset? localCenter}) {
return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
}
/// Get the current image widget size (for comparison to avoid unnecessary updates).
Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
/// Update the image widget size for center calculation.
void updateImageWidgetSize(Size size) {
_relativeMouse.updateImageWidgetSize(size);
}
void toggleRelativeMouseMode() {
_relativeMouse.toggleRelativeMouseMode();
}
bool setRelativeMouseMode(bool enabled) {
return _relativeMouse.setRelativeMouseMode(enabled);
}
/// Exit relative mouse mode and release all modifier keys to the remote.
/// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
/// We need to send key-up events for all modifiers because the shortcut itself may have
/// blocked some key events, leaving the remote in a state where modifiers are stuck.
void exitRelativeMouseModeWithKeyRelease() {
if (!_relativeMouse.enabled.value) return;
// First, send release events for all modifier keys to the remote.
// This ensures the remote doesn't have stuck modifier keys after exiting.
// Use press: false, down: false to send key-up events without modifiers attached.
final modifiersToRelease = [
'Control_L',
'Control_R',
'Alt_L',
'Alt_R',
'Shift_L',
'Shift_R',
'Meta_L', // Command/Super left
'Meta_R', // Command/Super right
];
for (final key in modifiersToRelease) {
bind.sessionInputKey(
sessionId: sessionId,
name: key,
down: false,
press: false,
alt: false,
ctrl: false,
shift: false,
command: false,
);
}
// Reset local modifier state
resetModifiers();
// Now exit relative mouse mode
_relativeMouse.setRelativeMouseMode(false);
}
void disposeRelativeMouseMode() {
_relativeMouse.dispose();
onRelativeMouseModeDisabled = null;
// Cancel the relative mouse mode observer and clean up global state.
_relativeMouseModeDisposer?.dispose();
_relativeMouseModeDisposer = null;
final peerId = id;
if (peerId.isNotEmpty) {
stateGlobal.relativeMouseModeState.remove(peerId);
}
}
void onWindowBlur() {
_relativeMouse.onWindowBlur();
}
void onWindowFocus() {
_relativeMouse.onWindowFocus();
}
void onPointHoverImage(PointerHoverEvent e) {
_stopFling = true;
if (isViewOnly && !showMyCursor) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
// Only update pointer region when relative mouse mode is enabled.
// This avoids unnecessary tracking when not in relative mode.
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (!isPhysicalMouse.value) {
isPhysicalMouse.value = true;
}
if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
}
}
@@ -1045,13 +1218,25 @@ class InputModel {
_windowRect = null;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (e.kind != ui.PointerDeviceKind.mouse) {
if (isPhysicalMouse.value) {
isPhysicalMouse.value = false;
}
}
if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
// In relative mouse mode, send button events without position.
// Use _relativeMouse.enabled.value consistently with the guard above.
if (_relativeMouse.enabled.value) {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
}
}
}
@@ -1059,9 +1244,21 @@ class InputModel {
if (isDesktop) _queryOtherWindowCoords = false;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (e.kind != ui.PointerDeviceKind.mouse) return;
if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
// In relative mouse mode, send button events without position.
// Use _relativeMouse.enabled.value consistently with the guard above.
if (_relativeMouse.enabled.value) {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
}
}
}
@@ -1069,6 +1266,11 @@ class InputModel {
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (_queryOtherWindowCoords) {
Future.delayed(Duration.zero, () async {
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
@@ -1076,7 +1278,10 @@ class InputModel {
_queryOtherWindowCoords = false;
}
if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
}
}
@@ -1100,6 +1305,11 @@ class InputModel {
return null;
}
/// Handle scroll/wheel events.
/// Note: Scroll events intentionally use absolute positioning even in relative mouse mode.
/// This is because scroll events don't need relative positioning - they represent
/// scroll deltas that are independent of cursor position. Games and 3D applications
/// handle scroll events the same way regardless of mouse mode.
void onPointerSignalImage(PointerSignalEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
@@ -1125,7 +1335,7 @@ class InputModel {
void refreshMousePos() => handleMouse({
'buttons': 0,
'type': _kMouseEventMove,
}, lastMousePos);
}, lastMousePos, edgeScroll: useEdgeScroll);
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
{
@@ -1232,6 +1442,7 @@ class InputModel {
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
if (isViewCamera) return null;
double x = offset.dx;
@@ -1273,6 +1484,7 @@ class InputModel {
onExit: onExit,
buttons: evt['buttons'],
moveCanvas: moveCanvas,
edgeScroll: edgeScroll,
);
if (pos == null) {
return null;
@@ -1285,14 +1497,18 @@ class InputModel {
evt['y'] = '${pos.y.toInt()}';
}
Map<int, String> mapButtons = {
kPrimaryMouseButton: 'left',
kSecondaryMouseButton: 'right',
kMiddleMouseButton: 'wheel',
kBackMouseButton: 'back',
kForwardMouseButton: 'forward'
};
evt['buttons'] = mapButtons[evt['buttons']] ?? '';
final buttons = evt['buttons'];
if (buttons is int) {
evt['buttons'] = mouseButtonsToPeer(buttons);
} else {
// Log warning if buttons exists but is not an int (unexpected caller).
// Keep empty string fallback for missing buttons to preserve move/hover behavior.
if (buttons != null) {
debugPrint(
'[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
}
evt['buttons'] = '';
}
return evt;
}
@@ -1301,9 +1517,10 @@ class InputModel {
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
final evtToPeer =
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas);
final evtToPeer = processEventToPeer(evt, offset,
onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
if (evtToPeer != null) {
bind.sessionSendMouse(
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
@@ -1320,6 +1537,7 @@ class InputModel {
bool onExit = false,
int buttons = kPrimaryMouseButton,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
final ffiModel = parent.target!.ffiModel;
CanvasCoords canvas =
@@ -1348,8 +1566,16 @@ class InputModel {
y -= CanvasModel.topToEdge;
x -= CanvasModel.leftToEdge;
if (isMove && moveCanvas) {
parent.target!.canvasModel.moveDesktopMouse(x, y);
if (isMove) {
final canvasModel = parent.target!.canvasModel;
if (edgeScroll) {
canvasModel.edgeScrollMouse(x, y);
} else if (moveCanvas) {
canvasModel.moveDesktopMouse(x, y);
}
canvasModel.updateLocalCursor(x, y);
}
return _handlePointerDevicePos(
@@ -1412,7 +1638,7 @@ class InputModel {
var nearBottom = (canvas.size.height - y) < nearThr;
final imageWidth = rect.width * canvas.scale;
final imageHeight = rect.height * canvas.scale;
if (canvas.scrollStyle == ScrollStyle.scrollbar) {
if (canvas.scrollStyle != ScrollStyle.scrollauto) {
x += imageWidth * canvas.scrollX;
y += imageHeight * canvas.scrollY;

View File

@@ -9,6 +9,7 @@ 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/scheduler.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/ab_model.dart';
@@ -29,6 +30,7 @@ import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/http_service.dart' as http;
import 'package:tuple/tuple.dart';
import 'package:image/image.dart' as img2;
import 'package:flutter_svg/flutter_svg.dart';
@@ -36,6 +38,7 @@ import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import 'package:window_manager/window_manager.dart';
import 'package:file_picker/file_picker.dart';
import 'package:vector_math/vector_math.dart' show Vector2;
import '../common.dart';
import '../utils/image.dart' as img;
@@ -156,6 +159,8 @@ class FfiModel with ChangeNotifier {
bool get isPeerAndroid => _pi.platform == kPeerPlatformAndroid;
bool get isPeerMobile => isPeerAndroid;
bool get isPeerLinux => _pi.platform == kPeerPlatformLinux;
bool get viewOnly => _viewOnly;
bool get showMyCursor => _showMyCursor;
@@ -176,6 +181,9 @@ class FfiModel with ChangeNotifier {
if (displays.isEmpty) {
return null;
}
if (isPeerLinux) {
useDisplayScale = true;
}
int scale(int len, double s) {
if (useDisplayScale) {
return len.toDouble() ~/ s;
@@ -205,6 +213,9 @@ class FfiModel with ChangeNotifier {
}
updatePermission(Map<String, dynamic> evt, String id) {
// Track previous keyboard permission to detect revocation.
final hadKeyboardPerm = _permissions['keyboard'] != false;
evt.forEach((k, v) {
if (k == 'name' || k.isEmpty) return;
_permissions[k] = v == 'true';
@@ -213,6 +224,18 @@ class FfiModel with ChangeNotifier {
if (parent.target?.connType == ConnType.defaultConn) {
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
}
// If keyboard permission was revoked while relative mouse mode is active,
// forcefully disable relative mouse mode to prevent the user from being trapped.
final hasKeyboardPerm = _permissions['keyboard'] != false;
if (hadKeyboardPerm && !hasKeyboardPerm) {
final inputModel = parent.target?.inputModel;
if (inputModel != null && inputModel.relativeMouseMode.value) {
inputModel.setRelativeMouseMode(false);
showToast(translate('rel-mouse-permission-lost-tip'));
}
}
debugPrint('updatePermission: $_permissions');
notifyListeners();
}
@@ -355,7 +378,7 @@ class FfiModel with ChangeNotifier {
parent.target?.fileModel.refreshAll();
}
} else if (name == 'job_error') {
parent.target?.fileModel.jobController.jobError(evt);
parent.target?.fileModel.handleJobError(evt);
} else if (name == 'override_file_confirm') {
parent.target?.fileModel.postOverrideFileConfirm(evt);
} else if (name == 'load_last_job') {
@@ -449,6 +472,9 @@ class FfiModel with ChangeNotifier {
_handlePrinterRequest(evt, sessionId, peerId);
} else if (name == 'screenshot') {
_handleScreenshot(evt, sessionId, peerId);
} else if (name == 'exit_relative_mouse_mode') {
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
} else {
debugPrint('Event is not handled in the fixed branch: $name');
}
@@ -757,7 +783,7 @@ class FfiModel with ChangeNotifier {
}
}
updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) {
Future<void> updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) async {
final newRect = displaysRect();
if (newRect == null) {
return;
@@ -769,9 +795,19 @@ class FfiModel with ChangeNotifier {
updateCursorPos: updateCursorPos);
}
_rect = newRect;
parent.target?.canvasModel
// Await updateViewStyle to ensure view geometry is fully updated before
// updating pointer lock center. This prevents stale center calculations.
await parent.target?.canvasModel
.updateViewStyle(refreshMousePos: updateCursorPos);
_updateSessionWidthHeight(sessionId);
// Keep pointer lock center in sync when using relative mouse mode.
// Note: updatePointerLockCenter is async-safe (handles errors internally),
// so we fire-and-forget here.
final inputModel = parent.target?.inputModel;
if (inputModel != null && inputModel.relativeMouseMode.value) {
inputModel.updatePointerLockCenter();
}
}
}
@@ -855,6 +891,17 @@ class FfiModel with ChangeNotifier {
final title = evt['title'];
final text = evt['text'];
final link = evt['link'];
// Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck.
if (title == 'Connection Error' ||
type == 'error' ||
type == 'restarting' ||
(type is String && type.contains('error'))) {
parent.target?.inputModel.setRelativeMouseMode(false);
}
if (type == 're-input-password') {
wrongPasswordDialog(sessionId, dialogManager, type, title, text);
} else if (type == 'input-2fa') {
@@ -931,11 +978,21 @@ class FfiModel with ChangeNotifier {
/// Show a message box with [type], [title] and [text].
showMsgBox(SessionID sessionId, String type, String title, String text,
String link, bool hasRetry, OverlayDialogManager dialogManager,
{bool? hasCancel}) {
msgBox(sessionId, type, title, text, link, dialogManager,
hasCancel: hasCancel,
reconnect: hasRetry ? reconnect : null,
reconnectTimeout: hasRetry ? _reconnects : null);
{bool? hasCancel}) async {
final showNoteEdit = parent.target != null &&
allowAskForNoteAtEndOfConnection(parent.target, false) &&
(title == "Connection Error" || type == "restarting") &&
!hasRetry;
if (showNoteEdit) {
await showConnEndAuditDialogCloseCanceled(
ffi: parent.target!, type: type, title: title, text: text);
closeConnection();
} else {
msgBox(sessionId, type, title, text, link, dialogManager,
hasCancel: hasCancel,
reconnect: hasRetry ? reconnect : null,
reconnectTimeout: hasRetry ? _reconnects : null);
}
_timer?.cancel();
if (hasRetry) {
_timer = Timer(Duration(seconds: _reconnects), () {
@@ -949,6 +1006,8 @@ class FfiModel with ChangeNotifier {
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
bool forceRelay) {
// Disable relative mouse mode before reconnecting to ensure cursor is released.
parent.target?.inputModel.setRelativeMouseMode(false);
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions();
dialogManager.dismissAll();
@@ -956,8 +1015,30 @@ class FfiModel with ChangeNotifier {
onCancel: closeConnection);
}
void showRelayHintDialog(SessionID sessionId, String type, String title,
String text, OverlayDialogManager dialogManager, String peerId) {
Future<void> showRelayHintDialog(
SessionID sessionId,
String type,
String title,
String text,
OverlayDialogManager dialogManager,
String peerId) async {
var hint = "\n\n${translate('relay_hint_tip')}";
if (text.contains("10054") || text.contains("104")) {
hint = "";
}
final text2 = "${translate(text)}$hint";
if (parent.target != null &&
allowAskForNoteAtEndOfConnection(parent.target, false) &&
pi.isSet.isTrue) {
if (await showConnEndAuditDialogCloseCanceled(
ffi: parent.target!, type: type, title: title, text: text2)) {
return;
}
closeConnection();
return;
}
dialogManager.show(tag: '$sessionId-$type', (setState, close, context) {
onClose() {
closeConnection();
@@ -966,13 +1047,10 @@ class FfiModel with ChangeNotifier {
final style =
ElevatedButton.styleFrom(backgroundColor: Colors.green[700]);
var hint = "\n\n${translate('relay_hint_tip')}";
if (text.contains("10054") || text.contains("104")) {
hint = "";
}
return CustomAlertDialog(
title: null,
content: msgboxContent(type, title, "${translate(text)}$hint"),
content: msgboxContent(type, title, text2),
actions: [
dialogButton('Close', onPressed: onClose, isOutline: true),
if (type == 'relay-hint')
@@ -1046,28 +1124,114 @@ class FfiModel with ChangeNotifier {
sessionId: sessionId,
display:
pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
width: _rect!.width.toInt(),
height: _rect!.height.toInt(),
width: displays[0].width,
height: displays[0].height,
);
} else {
for (int i = 0; i < displays.length; ++i) {
bind.sessionSetSize(
sessionId: sessionId,
display: i,
width: displays[i].width.toInt(),
height: displays[i].height.toInt(),
width: displays[i].width,
height: displays[i].height,
);
}
}
}
}
void _queryAuditGuid(String peerId) async {
try {
if (bind.isDisableAccount()) {
return;
}
if (bind
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn/active")
.isEmpty) {
return;
}
if (!mainGetLocalBoolOptionSync(
kOptionAllowAskForNoteAtEndOfConnection)) {
return;
}
if (bind.sessionGetAuditGuid(sessionId: sessionId).isNotEmpty) {
debugPrint('Get cached audit GUID');
return;
}
final url = bind.sessionGetAuditServerSync(
sessionId: sessionId, typ: "conn/active");
if (url.isEmpty) {
return;
}
final initialConnSessionId =
bind.sessionGetConnSessionId(sessionId: sessionId);
final connType = switch (parent.target?.connType) {
ConnType.defaultConn => 0,
ConnType.fileTransfer => 1,
ConnType.portForward => 2,
ConnType.rdp => 2,
ConnType.viewCamera => 3,
ConnType.terminal => 4,
_ => 0,
};
const retryIntervals = [1, 1, 2, 2, 3, 3];
for (int attempt = 1; attempt <= retryIntervals.length; attempt++) {
final currentConnSessionId =
bind.sessionGetConnSessionId(sessionId: sessionId);
if (currentConnSessionId != initialConnSessionId) {
debugPrint('connSessionId changed, stopping audit GUID query');
return;
}
final fullUrl =
'$url?id=$peerId&session_id=$currentConnSessionId&conn_type=$connType';
debugPrint(
'Querying audit GUID, attempt $attempt/${retryIntervals.length}');
try {
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
final response = await http.get(
Uri.parse(fullUrl),
headers: headers,
);
if (response.statusCode == 200) {
final guid = jsonDecode(response.body) as String?;
if (guid != null && guid.isNotEmpty) {
bind.sessionSetAuditGuid(sessionId: sessionId, guid: guid);
debugPrint('Successfully retrieved audit GUID');
return;
}
} else {
debugPrint(
'Failed to query audit GUID. Status: ${response.statusCode}, Body: ${response.body}');
return;
}
} catch (e) {
debugPrint('Error querying audit GUID (attempt $attempt): $e');
}
if (attempt < retryIntervals.length) {
await Future.delayed(Duration(seconds: retryIntervals[attempt - 1]));
}
}
debugPrint(
'Failed to retrieve audit GUID after ${retryIntervals.length} attempts');
} catch (e) {
debugPrint('Error in _queryAuditGuid: $e');
}
}
/// Handle the peer info event based on [evt].
handlePeerInfo(Map<String, dynamic> evt, String peerId, bool isCache) async {
parent.target?.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
// This call is to ensuer the keyboard mode is updated depending on the peer version.
parent.target?.inputModel.updateKeyboardMode();
_queryAuditGuid(peerId);
// Map clone is required here, otherwise "evt" may be changed by other threads through the reference.
// Because this function is asynchronous, there's an "await" in this function.
@@ -1080,6 +1244,17 @@ class FfiModel with ChangeNotifier {
parent.target?.dialogManager.dismissAll();
_pi.version = evt['version'];
// Note: Relative mouse mode is NOT auto-enabled on connect.
// Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M).
//
// For desktop/webDesktop, keyboard mode initialization is handled later by
// checkDesktopKeyboardMode() which may change the mode if not supported,
// followed by updateKeyboardMode() to sync InputModel.keyboardMode.
// For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web),
// but we call it here for consistency and future-proofing.
if (isMobile) {
parent.target?.inputModel.updateKeyboardMode();
}
_pi.isSupportMultiUiSession =
bind.isSupportMultiUiSession(version: _pi.version);
_pi.username = evt['username'];
@@ -1181,7 +1356,11 @@ class FfiModel with ChangeNotifier {
stateGlobal.resetLastResolutionGroupValues(peerId);
if (isDesktop || isWebDesktop) {
checkDesktopKeyboardMode();
// checkDesktopKeyboardMode may change the keyboard mode if the current
// mode is not supported. Re-sync InputModel.keyboardMode afterwards.
// Note: updateKeyboardMode() is a no-op on mobile (early-returns).
await checkDesktopKeyboardMode();
await parent.target?.inputModel.updateKeyboardMode();
}
notifyListeners();
@@ -1323,8 +1502,17 @@ class FfiModel with ChangeNotifier {
d.cursorEmbedded = evt['cursor_embedded'] == 1;
d.originalWidth = evt['original_width'] ?? kInvalidResolutionValue;
d.originalHeight = evt['original_height'] ?? kInvalidResolutionValue;
double v = (evt['scale']?.toDouble() ?? 100.0) / 100;
d._scale = v > 1.0 ? v : 1.0;
d._scale = 1.0;
final scaledWidth = evt['scaled_width'];
if (scaledWidth != null) {
final sw = int.tryParse(scaledWidth.toString());
if (sw != null && sw > 0 && d.width > 0) {
d._scale = max(d.width.toDouble() / sw, 1.0);
} else {
debugPrint(
"Invalid scaled_width ($scaledWidth) or width (${d.width}), using default scale 1.0");
}
}
return d;
}
@@ -1665,6 +1853,7 @@ class ImageModel with ChangeNotifier {
if (isDesktop || isWebDesktop) {
await parent.target?.canvasModel.updateViewStyle();
await parent.target?.canvasModel.updateScrollStyle();
await parent.target?.canvasModel.initializeEdgeScrollEdgeThickness();
}
if (parent.target != null) {
await initializeCursorAndCanvas(parent.target!);
@@ -1713,8 +1902,56 @@ class ImageModel with ChangeNotifier {
}
enum ScrollStyle {
scrollbar,
scrollauto,
scrollbar(kRemoteScrollStyleBar),
scrollauto(kRemoteScrollStyleAuto),
scrolledge(kRemoteScrollStyleEdge);
const ScrollStyle(this.stringValue);
final String stringValue;
String toJson() {
return name;
}
static ScrollStyle fromJson(String json, [ScrollStyle? fallbackValue]) {
switch (json) {
case 'scrollbar':
return scrollbar;
case 'scrollauto':
return scrollauto;
case 'scrolledge':
return scrolledge;
}
if (fallbackValue != null) {
return fallbackValue;
}
throw ArgumentError("Unknown ScrollStyle JSON value: '$json'");
}
@override
String toString() {
return stringValue;
}
static ScrollStyle fromString(String string, [ScrollStyle? fallbackValue]) {
switch (string) {
case kRemoteScrollStyleBar:
return scrollbar;
case kRemoteScrollStyleAuto:
return scrollauto;
case kRemoteScrollStyleEdge:
return scrolledge;
}
if (fallbackValue != null) {
return fallbackValue;
}
throw ArgumentError("Unknown ScrollStyle string value: '$string'");
}
}
class ViewStyle {
@@ -1789,6 +2026,60 @@ class ViewStyle {
}
}
enum EdgeScrollState {
inactive,
armed,
active,
}
class EdgeScrollFallbackState {
final CanvasModel _owner;
late Ticker _ticker;
Duration _lastTotalElapsed = Duration.zero;
bool _nextEventIsFirst = true;
Vector2 _encroachment = Vector2.zero();
EdgeScrollFallbackState(this._owner, TickerProvider tickerProvider) {
_ticker = tickerProvider.createTicker(emitTick);
}
void setEncroachment(Vector2 encroachment) {
_encroachment = encroachment;
}
void emitTick(Duration totalElapsed) {
if (_nextEventIsFirst) {
_lastTotalElapsed = totalElapsed;
_nextEventIsFirst = false;
} else {
final thisTickElapsed = totalElapsed - _lastTotalElapsed;
const double kFrameTime = 1000.0 / 60.0;
const double kSpeedFactor = 0.1;
var delta = _encroachment *
(kSpeedFactor * thisTickElapsed.inMilliseconds / kFrameTime);
_owner.performEdgeScroll(delta);
_lastTotalElapsed = totalElapsed;
}
}
void start() {
if (!_ticker.isActive) {
_nextEventIsFirst = true;
_ticker.start();
}
}
void stop() {
_ticker.stop();
}
}
class CanvasModel with ChangeNotifier {
// image offset of canvas
double _x = 0;
@@ -1810,6 +2101,15 @@ class CanvasModel with ChangeNotifier {
// scroll offset y percent
double _scrollY = 0.0;
ScrollStyle _scrollStyle = ScrollStyle.scrollauto;
// edge scroll mode: trigger scrolling when the cursor is close to the edge of the view
int _edgeScrollEdgeThickness = 100;
// tracks whether edge scroll should be active, prevents spurious
// scrolling when the cursor enters the view from outside
EdgeScrollState _edgeScrollState = EdgeScrollState.inactive;
// fallback strategy for when Bump Mouse isn't available
late EdgeScrollFallbackState _edgeScrollFallbackState;
// to avoid hammering a non-functional Bump Mouse
bool _bumpMouseIsWorking = true;
ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle();
Timer? _timerMobileFocusCanvasCursor;
@@ -1840,9 +2140,18 @@ class CanvasModel with ChangeNotifier {
_resetScroll() => setScrollPercent(0.0, 0.0);
setScrollPercent(double x, double y) {
_scrollX = x;
_scrollY = y;
void setScrollPercent(double x, double y) {
_scrollX = x.isFinite ? x : 0.0;
_scrollY = y.isFinite ? y : 0.0;
}
void pushScrollPositionToUI(double scrollPixelX, double scrollPixelY) {
if (_horizontal.hasClients) {
_horizontal.jumpTo(scrollPixelX);
}
if (_vertical.hasClients) {
_vertical.jumpTo(scrollPixelY);
}
}
ScrollController get scrollHorizontal => _horizontal;
@@ -1957,30 +2266,47 @@ class CanvasModel with ChangeNotifier {
}
tryUpdateScrollStyle(Duration duration, String? style) async {
if (_scrollStyle != ScrollStyle.scrollbar) return;
if (_scrollStyle == ScrollStyle.scrollauto) return;
style ??= await bind.sessionGetViewStyle(sessionId: sessionId);
if (style != kRemoteViewStyleOriginal && style != kRemoteViewStyleCustom) {
return;
}
_resetScroll();
Future.delayed(duration, () async {
updateScrollPercent();
});
}
updateScrollStyle() async {
Future<void> updateScrollStyle() async {
final style = await bind.sessionGetScrollStyle(sessionId: sessionId);
if (style == kRemoteScrollStyleBar) {
_scrollStyle = ScrollStyle.scrollbar;
_scrollStyle =
style != null ? ScrollStyle.fromString(style) : ScrollStyle.scrollauto;
if (_scrollStyle != ScrollStyle.scrollauto) {
_resetScroll();
} else {
_scrollStyle = ScrollStyle.scrollauto;
}
notifyListeners();
}
update(double x, double y, double scale) {
Future<void> initializeEdgeScrollEdgeThickness() async {
final savedValue =
await bind.sessionGetEdgeScrollEdgeThickness(sessionId: sessionId);
if (savedValue != null) {
_edgeScrollEdgeThickness = savedValue;
}
}
void updateEdgeScrollEdgeThickness(int newThickness) {
_edgeScrollEdgeThickness = newThickness;
notifyListeners();
}
void update(double x, double y, double scale) {
_x = x;
_y = y;
_scale = scale;
@@ -2007,7 +2333,33 @@ class CanvasModel with ChangeNotifier {
static double get windowBorderWidth => stateGlobal.windowBorderWidth.value;
static double get tabBarHeight => stateGlobal.tabBarHeight;
moveDesktopMouse(double x, double y) {
void activateLocalCursor() {
if (isDesktop || isWebDesktop) {
try {
RemoteCursorMovedState.find(id).value = false;
} catch (e) {
//
}
}
}
void updateLocalCursor(double x, double y) {
// If keyboard is not permitted, do not move cursor when mouse is moving.
if (parent.target != null && parent.target!.ffiModel.keyboard) {
// Draw cursor if is not desktop.
if (!(isDesktop || isWebDesktop)) {
parent.target!.cursorModel.moveLocal(x, y);
} else {
try {
RemoteCursorMovedState.find(id).value = false;
} catch (e) {
//
}
}
}
}
void moveDesktopMouse(double x, double y) {
if (size.width == 0 || size.height == 0) {
return;
}
@@ -2036,24 +2388,128 @@ class CanvasModel with ChangeNotifier {
if (dxOffset != 0 || dyOffset != 0) {
notifyListeners();
}
}
// If keyboard is not permitted, do not move cursor when mouse is moving.
if (parent.target != null && parent.target!.ffiModel.keyboard) {
// Draw cursor if is not desktop.
if (!(isDesktop || isWebDesktop)) {
parent.target!.cursorModel.moveLocal(x, y);
void initializeEdgeScrollFallback(TickerProvider tickerProvider) {
_edgeScrollFallbackState = EdgeScrollFallbackState(this, tickerProvider);
}
void disableEdgeScroll() {
_edgeScrollState = EdgeScrollState.inactive;
cancelEdgeScroll();
}
void rearmEdgeScroll() {
_edgeScrollState = EdgeScrollState.armed;
}
void cancelEdgeScroll() {
_edgeScrollFallbackState.stop();
}
(Vector2, Vector2) getScrollInfo() {
final scrollPixel = Vector2(
_horizontal.hasClients ? _horizontal.position.pixels : 0,
_vertical.hasClients ? _vertical.position.pixels : 0);
final max = Vector2(
_horizontal.hasClients ? _horizontal.position.maxScrollExtent : 0,
_vertical.hasClients ? _vertical.position.maxScrollExtent : 0);
return (scrollPixel, max);
}
void edgeScrollMouse(double x, double y) async {
if ((_edgeScrollState == EdgeScrollState.inactive) ||
(size.width == 0 || size.height == 0) ||
!(_horizontal.hasClients || _vertical.hasClients)) {
return;
}
if (_edgeScrollState == EdgeScrollState.armed) {
// Edge scroll is armed to become active once the cursor
// is observed within the rectangle interior to the
// edge scroll regions. If the user has just moved the
// cursor in from outside of the window, edge scrolling
// doesn't happen yet.
final clientArea = Rect.fromLTWH(0, 0, size.width, size.height);
final innerZone = clientArea.deflate(_edgeScrollEdgeThickness.toDouble());
if (innerZone.contains(Offset(x, y))) {
_edgeScrollState = EdgeScrollState.active;
} else {
try {
RemoteCursorMovedState.find(id).value = false;
} catch (e) {
//
}
// Not yet.
return;
}
}
var dxOffset = 0.0;
var dyOffset = 0.0;
if (x < _edgeScrollEdgeThickness) {
dxOffset = x - _edgeScrollEdgeThickness;
} else if (x >= size.width - _edgeScrollEdgeThickness) {
dxOffset = x - (size.width - _edgeScrollEdgeThickness);
}
if (y < _edgeScrollEdgeThickness) {
dyOffset = y - _edgeScrollEdgeThickness;
} else if (y >= size.height - _edgeScrollEdgeThickness) {
dyOffset = y - (size.height - _edgeScrollEdgeThickness);
}
var encroachment = Vector2(dxOffset, dyOffset);
var (scrollPixel, max) = getScrollInfo();
encroachment.clamp(-scrollPixel, max - scrollPixel);
if (encroachment.length2 == 0) {
_edgeScrollFallbackState.stop();
} else {
var bumpAmount = -encroachment;
// Round away from 0: this ensures that the mouse will be bumped clear of
// whichever edge scroll zone(s) it is in
bumpAmount.x += bumpAmount.x.sign * 0.5;
bumpAmount.y += bumpAmount.y.sign * 0.5;
var bumpMouseSucceeded = _bumpMouseIsWorking &&
(await rustDeskWinManager.call(WindowType.Main, kWindowBumpMouse,
{"dx": bumpAmount.x.round(), "dy": bumpAmount.y.round()}))
.result;
if (bumpMouseSucceeded) {
performEdgeScroll(encroachment);
} else {
// If we can't BumpMouse, then we switch to slower scrolling with autorepeat
// Don't keep hammering BumpMouse if it's not working.
_bumpMouseIsWorking = false;
// Keep scrolling as long as the user is overtop of an edge.
_edgeScrollFallbackState.setEncroachment(encroachment);
_edgeScrollFallbackState.start();
}
}
}
set scale(v) {
_scale = v;
void performEdgeScroll(Vector2 delta) {
var (scrollPixel, max) = getScrollInfo();
scrollPixel += delta;
scrollPixel.clamp(Vector2.zero(), max);
var scrollPixelPercent = scrollPixel.clone();
scrollPixelPercent.divide(max);
scrollPixelPercent.scale(100.0);
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
notifyListeners();
}
@@ -2590,9 +3046,10 @@ class CursorModel with ChangeNotifier {
var cx = r.center.dx;
var cy = r.center.dy;
var tryMoveCanvasX = false;
final displayRect = parent.target?.ffiModel.rect;
if (dx > 0) {
final maxCanvasCanMove = _displayOriginX +
(parent.target?.imageModel.image!.width ?? 1280) -
(displayRect?.width ?? 1280) -
r.right.roundToDouble();
tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0;
if (tryMoveCanvasX) {
@@ -2614,7 +3071,7 @@ class CursorModel with ChangeNotifier {
var tryMoveCanvasY = false;
if (dy > 0) {
final mayCanvasCanMove = _displayOriginY +
(parent.target?.imageModel.image!.height ?? 720) -
(displayRect?.height ?? 720) -
r.bottom.roundToDouble();
tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0;
if (tryMoveCanvasY) {
@@ -3035,7 +3492,6 @@ class FFI {
var version = '';
var connType = ConnType.defaultConn;
var closed = false;
var auditNote = '';
/// dialogManager use late to ensure init after main page binding [globalKey]
late final dialogManager = OverlayDialogManager();
@@ -3126,7 +3582,6 @@ class FFI {
List<int>? displays,
}) {
closed = false;
auditNote = '';
if (isMobile) mobileReset();
assert(
(!(isPortForward && isViewCamera)) &&
@@ -3318,6 +3773,7 @@ class FFI {
dialogManager.dismissAll();
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
@@ -3365,6 +3821,8 @@ class FFI {
ffiModel.clear();
canvasModel.clear();
inputModel.resetModifiers();
// Dispose relative mouse mode resources to ensure cursor is restored
inputModel.disposeRelativeMouseMode();
if (closeSession) {
await bind.sessionClose(sessionId: sessionId);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:get/get.dart';
@@ -30,6 +29,11 @@ class StateGlobal {
String _inputSource = '';
// Track relative mouse mode state for each peer connection.
// Key: peerId, Value: true if relative mouse mode is active.
// Note: This is session-only runtime state, NOT persisted to config.
final RxMap<String, bool> relativeMouseModeState = <String, bool>{}.obs;
// Use for desktop -> remote toolbar -> resolution
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};

View File

@@ -27,6 +27,8 @@ class TerminalModel with ChangeNotifier {
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
void Function(int w, int h, int pw, int ph)? onResizeExternal;
Future<void> _handleInput(String data) async {
// If we press the `Enter` button on Android,
// `data` can be '\r' or '\n' when using different keyboards.
@@ -68,6 +70,10 @@ class TerminalModel with ChangeNotifier {
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
debugPrint(
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
// This piece of code must be placed before the conditional check in order to initialize properly.
onResizeExternal?.call(w, h, pw, ph);
if (_terminalOpened) {
// Notify remote terminal of resize
try {
@@ -140,6 +146,10 @@ class TerminalModel with ChangeNotifier {
}
}
Future<void> sendVirtualKey(String data) async {
return _handleInput(data);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {

View File

@@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:http/http.dart' as http;
import '../models/platform_model.dart';
import 'package:flutter_hbb/common.dart';
export 'package:http/http.dart' show Response;
enum HttpMethod { get, post, put, delete }
@@ -15,11 +17,19 @@ class HttpService {
}) async {
headers ??= {'Content-Type': 'application/json'};
// Determine if there is currently a proxy setting, and if so, use FFI to call the Rust HTTP method.
final isProxy = await bind.mainGetProxyStatus();
// Use Rust HTTP implementation for non-web platforms for consistency.
var useFlutterHttp = (isWeb || kIsWeb);
if (!useFlutterHttp) {
final enableFlutterHttpOnRust =
mainGetLocalBoolOptionSync(kOptionEnableFlutterHttpOnRust);
// Use flutter http if:
// Not `enableFlutterHttpOnRust` and no proxy is set
useFlutterHttp =
!(enableFlutterHttpOnRust || await bind.mainGetProxyStatus());
}
if (!isProxy) {
return await _pollFultterHttp(url, method, headers: headers, body: body);
if (useFlutterHttp) {
return await _pollFlutterHttp(url, method, headers: headers, body: body);
}
String headersJson = jsonEncode(headers);
@@ -34,7 +44,7 @@ class HttpService {
return _parseHttpResponse(resJson);
}
Future<http.Response> _pollFultterHttp(
Future<http.Response> _pollFlutterHttp(
Uri url,
HttpMethod method, {
Map<String, String>? headers,
@@ -87,7 +97,8 @@ class HttpService {
int statusCode = parsedJson['status_code'];
return http.Response(body, statusCode, headers: headers);
} catch (e) {
throw Exception('Failed to parse response: $e');
print('Failed to parse response\n$responseJson\nError:\n$e');
throw Exception('Failed to parse response.\n$responseJson');
}
}
}

View File

@@ -13,8 +13,18 @@ class RdPlatformChannel {
static RdPlatformChannel get instance => _windowUtil;
final MethodChannel _osxMethodChannel =
MethodChannel("org.rustdesk.rustdesk/macos");
final MethodChannel _hostMethodChannel =
MethodChannel("org.rustdesk.rustdesk/host");
/// Bump the position of the mouse cursor, if applicable
Future<bool> bumpMouse({required int dx, required int dy}) async {
// No debug output; this call is too chatty.
bool? result = await _hostMethodChannel
.invokeMethod("bumpMouse", {"dx": dx, "dy": dy});
return result ?? false;
}
/// Change the theme of the system window
Future<void> changeSystemWindowTheme(SystemWindowTheme theme) {
@@ -23,13 +33,13 @@ class RdPlatformChannel {
print(
"[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}");
}
return _osxMethodChannel
return _hostMethodChannel
.invokeMethod("setWindowTheme", {"themeName": theme.name});
}
/// Terminate .app manually.
Future<void> terminate() {
assert(isMacOS);
return _osxMethodChannel.invokeMethod("terminate");
return _hostMethodChannel.invokeMethod("terminate");
}
}

View File

@@ -0,0 +1,58 @@
/// A small helper for accumulating fractional mouse deltas and emitting integer deltas.
///
/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas
/// are doubles. This accumulator preserves sub-pixel movement by carrying the
/// fractional remainder across events.
class RelativeMouseDelta {
final int x;
final int y;
const RelativeMouseDelta(this.x, this.y);
}
/// Accumulates fractional mouse deltas and returns integer deltas when available.
class RelativeMouseAccumulator {
double _fracX = 0.0;
double _fracY = 0.0;
/// Adds a delta and returns an integer delta when at least one axis reaches a
/// magnitude of 1px (after truncation towards zero).
///
/// If [maxDelta] is > 0, the returned integer delta is clamped to
/// [-maxDelta, maxDelta] on each axis.
RelativeMouseDelta? add(
double dx,
double dy, {
required int maxDelta,
}) {
// Guard against misuse: negative maxDelta would silently disable clamping.
assert(maxDelta >= 0, 'maxDelta must be non-negative');
_fracX += dx;
_fracY += dy;
int intX = _fracX.truncate();
int intY = _fracY.truncate();
if (intX == 0 && intY == 0) {
return null;
}
// Clamp before subtracting so excess movement is preserved in the accumulator
// rather than being permanently discarded during spikes.
if (maxDelta > 0) {
intX = intX.clamp(-maxDelta, maxDelta);
intY = intY.clamp(-maxDelta, maxDelta);
}
_fracX -= intX;
_fracY -= intY;
return RelativeMouseDelta(intX, intY);
}
void reset() {
_fracX = 0.0;
_fracY = 0.0;
}
}

View File

@@ -812,7 +812,7 @@ class RustdeskImpl {
}
String mainGetAppNameSync({dynamic hint}) {
return 'RustDesk';
return js.context.callMethod('getByName', ['app-name']);
}
String mainUriPrefixSync({dynamic hint}) {
@@ -1609,23 +1609,28 @@ class RustdeskImpl {
}
bool isCustomClient({dynamic hint}) {
return false;
// is_custom_client() checks if app name is not "RustDesk"
return mainGetAppNameSync(hint: hint) != "RustDesk";
}
bool isDisableSettings({dynamic hint}) {
return false;
// Checks HARD_SETTINGS["disable-settings"] == "Y"
return mainGetHardOption(key: "disable-settings", hint: hint) == "Y";
}
bool isDisableAb({dynamic hint}) {
return false;
// Checks HARD_SETTINGS["disable-ab"] == "Y"
return mainGetHardOption(key: "disable-ab", hint: hint) == "Y";
}
bool isDisableGroupPanel({dynamic hint}) {
return false;
// Checks LocalConfig::get_option("disable-group-panel") == "Y"
return mainGetLocalOption(key: "disable-group-panel", hint: hint) == "Y";
}
bool isDisableAccount({dynamic hint}) {
return false;
// Checks HARD_SETTINGS["disable-account"] == "Y"
return mainGetHardOption(key: "disable-account", hint: hint) == "Y";
}
bool isDisableInstallation({dynamic hint}) {
@@ -1748,7 +1753,7 @@ class RustdeskImpl {
}
String mainGetHardOption({required String key, dynamic hint}) {
throw UnimplementedError("mainGetHardOption");
return mainGetLocalOption(key: key, hint: hint);
}
Future<void> mainCheckHwcodec({dynamic hint}) {
@@ -1821,7 +1826,7 @@ class RustdeskImpl {
}
String mainGetBuildinOption({required String key, dynamic hint}) {
return '';
return mainGetLocalOption(key: key, hint: hint);
}
String installInstallOptions({dynamic hint}) {
@@ -1979,5 +1984,55 @@ class RustdeskImpl {
]));
}
Future<int?> sessionGetEdgeScrollEdgeThickness(
{required UuidValue sessionId, dynamic hint}) {
final thickness = js.context.callMethod(
'getByName', ['option:session', 'edge-scroll-edge-thickness']);
return Future(() => int.tryParse(thickness) ?? 100);
}
Future<void> sessionSetEdgeScrollEdgeThickness(
{required UuidValue sessionId, required int value, dynamic hint}) {
return Future(() => js.context.callMethod('setByName',
['option:session', 'edge-scroll-edge-thickness', value.toString()]));
}
String sessionGetConnSessionId({required UuidValue sessionId, dynamic hint}) {
return js.context.callMethod('getByName', ['conn_session_id']);
}
bool willSessionCloseCloseSession(
{required UuidValue sessionId, dynamic hint}) {
return true;
}
String sessionGetLastAuditNote({required UuidValue sessionId, dynamic hint}) {
return js.context.callMethod('getByName', ['last_audit_note']);
}
Future<void> sessionSetAuditGuid(
{required UuidValue sessionId, required String guid, dynamic hint}) {
return Future(
() => js.context.callMethod('setByName', ['audit_guid', guid]));
}
String sessionGetAuditGuid({required UuidValue sessionId, dynamic hint}) {
return js.context.callMethod('getByName', ['audit_guid']);
}
bool mainSetCursorPosition({required int x, required int y, dynamic hint}) {
return false;
}
bool mainClipCursor(
{required int left,
required int top,
required int right,
required int bottom,
required bool enable,
dynamic hint}) {
return false;
}
void dispose() {}
}

View File

@@ -63,6 +63,8 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"bump_mouse.cc"
"bump_mouse_x11.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)

View File

@@ -0,0 +1,18 @@
#include "bump_mouse.h"
#include "bump_mouse_x11.h"
#include <gdk/gdkx.h>
bool bump_mouse(int dx, int dy)
{
GdkDisplay *display = gdk_display_get_default();
if (GDK_IS_X11_DISPLAY(display)) {
return bump_mouse_x11(dx, dy);
}
else {
// Don't know how to support this.
return false;
}
}

View File

@@ -0,0 +1,3 @@
#pragma once
bool bump_mouse(int dx, int dy);

View File

@@ -0,0 +1,30 @@
#include "bump_mouse.h"
#include <gtk/gtk.h>
#include <gdk/gdkx.h>
#include <iostream>
bool bump_mouse_x11(int dx, int dy)
{
GdkDevice *mouse_device;
#if GTK_CHECK_VERSION(3, 20, 0)
auto seat = gdk_display_get_default_seat(gdk_display_get_default());
mouse_device = gdk_seat_get_pointer(seat);
#else
auto devman = gdk_display_get_device_manager(gdk_display_get_default());
mouse_device = gdk_device_manager_get_client_pointer(devman);
#endif
GdkScreen *screen;
gint x, y;
gdk_device_get_position(mouse_device, &screen, &x, &y);
gdk_device_warp(mouse_device, screen, x + dx, y + dy);
return true;
}

View File

@@ -0,0 +1,3 @@
#pragma once
bool bump_mouse_x11(int dx, int dy);

View File

@@ -1,5 +1,7 @@
#include "my_application.h"
#include "bump_mouse.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
@@ -10,10 +12,13 @@
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
FlMethodChannel* host_channel;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data);
GtkWidget *find_gl_area(GtkWidget *widget);
void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
@@ -24,10 +29,11 @@ GtkWidget *find_gl_area(GtkWidget *widget);
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
gtk_window_set_decorated(window, FALSE);
// try setting icon for rustdesk, which uses the system cache
// try setting icon for rustdesk, which uses the system cache
GtkIconTheme* theme = gtk_icon_theme_get_default();
gint icons[4] = {256, 128, 64, 32};
for (int i = 0; i < 4; i++) {
@@ -87,6 +93,17 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->host_channel = fl_method_channel_new(
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
"org.rustdesk.rustdesk/host",
FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(
self->host_channel,
host_channel_call_handler,
self,
nullptr);
gtk_widget_grab_focus(GTK_WIDGET(view));
}
@@ -113,6 +130,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
g_clear_object(&self->host_channel);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
@@ -131,6 +149,61 @@ MyApplication* my_application_new() {
nullptr));
}
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data)
{
if (strcmp(fl_method_call_get_name(method_call), "bumpMouse") == 0) {
FlValue *args = fl_method_call_get_args(method_call);
FlValue *dxValue = nullptr;
FlValue *dyValue = nullptr;
switch (fl_value_get_type(args))
{
case FL_VALUE_TYPE_MAP:
{
dxValue = fl_value_lookup_string(args, "dx");
dyValue = fl_value_lookup_string(args, "dy");
break;
}
case FL_VALUE_TYPE_LIST:
{
int listSize = fl_value_get_length(args);
dxValue = (listSize >= 1) ? fl_value_get_list_value(args, 0) : nullptr;
dyValue = (listSize >= 2) ? fl_value_get_list_value(args, 1) : nullptr;
break;
}
default: break;
}
int dx = 0, dy = 0;
if (dxValue && (fl_value_get_type(dxValue) == FL_VALUE_TYPE_INT)) {
dx = fl_value_get_int(dxValue);
}
if (dyValue && (fl_value_get_type(dyValue) == FL_VALUE_TYPE_INT)) {
dy = fl_value_get_int(dyValue);
}
bool result = bump_mouse(dx, dy);
FlValue *result_value = fl_value_new_bool(result);
GError *error = nullptr;
if (!fl_method_call_respond_success(method_call, result_value, &error)) {
g_warning("Failed to send Flutter Platform Channel response: %s", error->message);
g_error_free(error);
}
fl_value_unref(result_value);
}
}
GtkWidget *find_gl_area(GtkWidget *widget)
{
if (GTK_IS_GL_AREA(widget)) {
@@ -160,7 +233,7 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view)
GtkWidget *gl_area = NULL;
printf("Try setting transparent\n");
gl_area = find_gl_area(GTK_WIDGET(view));
if (gl_area != NULL) {
gtk_gl_area_set_has_alpha(GTK_GL_AREA(gl_area), TRUE);

View File

@@ -19,6 +19,22 @@ import window_manager
import window_size
import texture_rgba_renderer
// Global state for relative mouse mode
// All properties and methods must be accessed on the main thread since they
// interact with NSEvent monitors, CoreGraphics APIs, and Flutter channels.
// Note: We avoid @MainActor to maintain macOS 10.14 compatibility.
class RelativeMouseState {
static let shared = RelativeMouseState()
var enabled = false
var eventMonitor: Any?
var deltaChannel: FlutterMethodChannel?
var accumulatedDeltaX: CGFloat = 0
var accumulatedDeltaY: CGFloat = 0
private init() {}
}
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
rustdesk_core_main();
@@ -29,7 +45,7 @@ class MainFlutterWindow: NSWindow {
// register self method handler
let registrar = flutterViewController.registrar(forPlugin: "RustDeskPlugin")
setMethodHandler(registrar: registrar)
RegisterGeneratedPlugins(registry: flutterViewController)
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
@@ -50,22 +66,120 @@ class MainFlutterWindow: NSWindow {
WindowSizePlugin.register(with: controller.registrar(forPlugin: "WindowSizePlugin"))
TextureRgbaRendererPlugin.register(with: controller.registrar(forPlugin: "TextureRgbaRendererPlugin"))
}
super.awakeFromNib()
}
override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) {
super.order(place, relativeTo: otherWin)
hiddenWindowAtLaunch()
}
/// Override window theme.
public func setWindowInterfaceMode(window: NSWindow, themeName: String) {
window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua)
}
private func enableNativeRelativeMouseMode(channel: FlutterMethodChannel) -> Bool {
assert(Thread.isMainThread, "enableNativeRelativeMouseMode must be called on the main thread")
let state = RelativeMouseState.shared
if state.enabled {
// Already enabled: update the channel so this caller receives deltas.
state.deltaChannel = channel
return true
}
// Dissociate mouse from cursor position - this locks the cursor in place
// Do this FIRST before setting any state
let result = CGAssociateMouseAndMouseCursorPosition(0)
if result != CGError.success {
NSLog("[RustDesk] Failed to dissociate mouse from cursor position: %d", result.rawValue)
return false
}
// Only set state after CG call succeeds
state.deltaChannel = channel
state.accumulatedDeltaX = 0
state.accumulatedDeltaY = 0
// Add local event monitor to capture mouse delta.
// Note: Local event monitors are always called on the main thread,
// so accessing main-thread-only state is safe here.
state.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak state] event in
guard let state = state else { return event }
// Guard against race: mode may be disabled between weak capture and this check.
guard state.enabled else { return event }
let deltaX = event.deltaX
let deltaY = event.deltaY
if deltaX != 0 || deltaY != 0 {
// Accumulate delta (main thread only - NSEvent local monitors always run on main thread)
state.accumulatedDeltaX += deltaX
state.accumulatedDeltaY += deltaY
// Only send if we have integer movement
let intX = Int(state.accumulatedDeltaX)
let intY = Int(state.accumulatedDeltaY)
if intX != 0 || intY != 0 {
state.accumulatedDeltaX -= CGFloat(intX)
state.accumulatedDeltaY -= CGFloat(intY)
// Send delta to Flutter (already on main thread)
state.deltaChannel?.invokeMethod("onMouseDelta", arguments: ["dx": intX, "dy": intY])
}
}
return event
}
// Check if monitor was created successfully
if state.eventMonitor == nil {
NSLog("[RustDesk] Failed to create event monitor for relative mouse mode")
// Re-associate mouse since we failed
CGAssociateMouseAndMouseCursorPosition(1)
state.deltaChannel = nil
return false
}
// Set enabled LAST after everything succeeds
state.enabled = true
return true
}
private func disableNativeRelativeMouseMode() {
assert(Thread.isMainThread, "disableNativeRelativeMouseMode must be called on the main thread")
let state = RelativeMouseState.shared
if !state.enabled { return }
state.enabled = false
// Remove event monitor
if let monitor = state.eventMonitor {
NSEvent.removeMonitor(monitor)
state.eventMonitor = nil
}
state.deltaChannel = nil
state.accumulatedDeltaX = 0
state.accumulatedDeltaY = 0
// Re-associate mouse with cursor position (non-blocking with async retry)
let result = CGAssociateMouseAndMouseCursorPosition(1)
if result != CGError.success {
NSLog("[RustDesk] Failed to re-associate mouse with cursor position: %d, scheduling retry...", result.rawValue)
// Non-blocking retry after 50ms
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let retryResult = CGAssociateMouseAndMouseCursorPosition(1)
if retryResult != CGError.success {
NSLog("[RustDesk] Retry failed to re-associate mouse: %d. Cursor may remain locked.", retryResult.rawValue)
}
}
}
}
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/macos", binaryMessenger: registrar.messenger)
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
channel.setMethodCallHandler({
(call, result) -> Void in
switch call.method {
@@ -96,9 +210,74 @@ class MainFlutterWindow: NSWindow {
}
case "requestRecordAudio":
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
result(granted)
DispatchQueue.main.async {
result(granted)
}
})
break
case "bumpMouse":
var dx = 0
var dy = 0
if let argMap = call.arguments as? [String: Any] {
dx = (argMap["dx"] as? Int) ?? 0
dy = (argMap["dy"] as? Int) ?? 0
}
else if let argList = call.arguments as? [Any] {
dx = argList.count >= 1 ? (argList[0] as? Int) ?? 0 : 0
dy = argList.count >= 2 ? (argList[1] as? Int) ?? 0 : 0
}
var mouseLoc: CGPoint
if let dummyEvent = CGEvent(source: nil) { // can this ever fail?
mouseLoc = dummyEvent.location
}
else if let screenFrame = NSScreen.screens.first?.frame {
// NeXTStep: Origin is lower-left of primary screen, positive is up
// Cocoa Core Graphics: Origin is upper-left of primary screen, positive is down
let nsMouseLoc = NSEvent.mouseLocation
mouseLoc = CGPoint(
x: nsMouseLoc.x,
y: NSHeight(screenFrame) - nsMouseLoc.y)
}
else {
result(false)
break
}
let newLoc = CGPoint(x: mouseLoc.x + CGFloat(dx), y: mouseLoc.y + CGFloat(dy))
CGDisplayMoveCursorToPoint(0, newLoc)
// By default, Cocoa suppresses mouse events briefly after a call to warp the
// cursor to a new location. This is good if you want to draw the user's
// attention to the fact that the mouse is now in a particular location, but
// it's bad in this case; we get called as part of the handling of edge
// scrolling, which means the mouse is typically still in motion, and we want
// the cursor to keep moving smoothly uninterrupted.
//
// This function's main action is to toggle whether the mouse cursor is
// associated with the mouse position, but setting it to true when it's
// already true has the side-effect of cancelling this motion suppression.
//
// However, we must NOT call this when relative mouse mode is active,
// as it would break the pointer lock established by enableNativeRelativeMouseMode.
if !RelativeMouseState.shared.enabled {
CGAssociateMouseAndMouseCursorPosition(1 /* true */)
}
result(true)
case "enableNativeRelativeMouseMode":
let success = self.enableNativeRelativeMouseMode(channel: channel)
result(success)
case "disableNativeRelativeMouseMode":
self.disableNativeRelativeMouseMode()
result(true)
default:
result(FlutterMethodNotImplemented)
}

View File

@@ -1,2 +1,10 @@
#!/usr/bin/env bash
#
# Fix OpenSSL build with Android NDK clang on 32-bit architectures
#
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter

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.4.3+61
version: 1.4.5+63
environment:
sdk: '^3.1.0'
@@ -109,6 +109,7 @@ dependencies:
xterm: 4.0.0
sqflite: 2.2.0
google_fonts: ^6.2.1
vector_math: ^2.1.4
dev_dependencies:
icons_launcher: ^2.0.4

View File

@@ -1,13 +1,24 @@
#include "flutter_window.h"
#include <optional>
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include <texture_rgba_renderer/texture_rgba_renderer_plugin_c_api.h>
#include <flutter_gpu_texture_renderer/flutter_gpu_texture_renderer_plugin_c_api.h>
#include "flutter/generated_plugin_registrant.h"
#include <flutter/event_channel.h>
#include <flutter/event_sink.h>
#include <flutter/event_stream_handler_functions.h>
#include <flutter/method_channel.h>
#include <flutter/standard_method_codec.h>
#include <windows.h>
#include <optional>
#include <memory>
#include "win32_desktop.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
@@ -29,6 +40,48 @@ bool FlutterWindow::OnCreate() {
return false;
}
RegisterPlugins(flutter_controller_->engine());
flutter::MethodChannel<> channel(
flutter_controller_->engine()->messenger(),
"org.rustdesk.rustdesk/host",
&flutter::StandardMethodCodec::GetInstance());
channel.SetMethodCallHandler(
[](const flutter::MethodCall<>& call, std::unique_ptr<flutter::MethodResult<>> result) {
if (call.method_name() == "bumpMouse") {
auto arguments = call.arguments();
int dx = 0, dy = 0;
if (std::holds_alternative<flutter::EncodableMap>(*arguments)) {
auto argsMap = std::get<flutter::EncodableMap>(*arguments);
auto dxIt = argsMap.find(flutter::EncodableValue("dx"));
auto dyIt = argsMap.find(flutter::EncodableValue("dy"));
if ((dxIt != argsMap.end()) && std::holds_alternative<int>(dxIt->second)) {
dx = std::get<int>(dxIt->second);
}
if ((dyIt != argsMap.end()) && std::holds_alternative<int>(dyIt->second)) {
dy = std::get<int>(dyIt->second);
}
} else if (std::holds_alternative<flutter::EncodableList>(*arguments)) {
auto argsList = std::get<flutter::EncodableList>(*arguments);
if ((argsList.size() >= 1) && std::holds_alternative<int>(argsList[0])) {
dx = std::get<int>(argsList[0]);
}
if ((argsList.size() >= 2) && std::holds_alternative<int>(argsList[1])) {
dy = std::get<int>(argsList[1]);
}
}
bool succeeded = Win32Desktop::BumpMouse(dx, dy);
result->Success(succeeded);
}
});
DesktopMultiWindowSetWindowCreatedCallback([](void *controller) {
auto *flutter_view_controller =
reinterpret_cast<flutter::FlutterViewController *>(controller);

View File

@@ -66,4 +66,17 @@ namespace Win32Desktop
size.width = std::min(size.width, workarea_bottom_right.x - origin.x);
size.height = std::min(size.height, workarea_bottom_right.y - origin.y);
}
bool BumpMouse(int dx, int dy)
{
POINT pos;
if (GetCursorPos(&pos))
{
SetCursorPos(pos.x + dx, pos.y + dy);
return true;
}
return false;
}
}

View File

@@ -7,6 +7,7 @@ namespace Win32Desktop
{
void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
bool BumpMouse(int dx, int dy);
}
#endif // RUNNER_WIN32_DESKTOP_H_

View File

@@ -208,42 +208,56 @@ impl MouseControllable for Enigo {
}
fn mouse_move_to(&mut self, x: i32, y: i32) {
let pressed = Self::pressed_buttons();
let event_type = if pressed & 1 > 0 {
CGEventType::LeftMouseDragged
} else if pressed & 2 > 0 {
CGEventType::RightMouseDragged
} else {
CGEventType::MouseMoved
};
let dest = CGPoint::new(x as f64, y as f64);
if let Some(src) = self.event_source.as_ref() {
if let Ok(event) =
CGEvent::new_mouse_event(src.clone(), event_type, dest, CGMouseButton::Left)
{
self.post(event, None);
}
}
// For absolute movement, we don't set delta values
// This maintains backward compatibility
self.mouse_move_to_impl(x, y, None);
}
fn mouse_move_relative(&mut self, x: i32, y: i32) {
let (display_width, display_height) = Self::main_display_size();
let (current_x, y_inv) = Self::mouse_location_raw_coords();
let current_y = (display_height as i32) - y_inv;
let new_x = current_x + x;
let new_y = current_y + y;
// Use saturating arithmetic to prevent overflow/wraparound
let mut new_x = current_x.saturating_add(x);
let mut new_y = current_y.saturating_add(y);
if new_x < 0
|| new_x as usize > display_width
|| new_y < 0
|| new_y as usize > display_height
{
return;
// Define screen center and edge margins for cursor reset
let center_x = (display_width / 2) as i32;
let center_y = (display_height / 2) as i32;
// Margin calculation: 5% of the smaller screen dimension with a minimum of 50px.
// This provides a comfortable buffer zone to detect when the cursor is approaching
// screen edges, allowing us to reset it to center before it hits the boundary.
// This ensures continuous relative mouse movement without getting stuck at edges.
let margin = (display_width.min(display_height) / 20).max(50) as i32;
// Check if cursor is approaching screen boundaries
// Use saturating_sub to prevent negative thresholds on very small displays
let right = (display_width as i32).saturating_sub(margin);
let bottom = (display_height as i32).saturating_sub(margin);
let near_edge = new_x < margin
|| new_x > right
|| new_y < margin
|| new_y > bottom;
if near_edge {
// Reset cursor to screen center to allow continuous movement
// The delta values are still passed correctly for games/apps
new_x = center_x;
new_y = center_y;
}
self.mouse_move_to(new_x, new_y);
// Clamp to screen bounds as a safety measure.
// Use saturating_sub(1) to ensure coordinates don't exceed the last valid pixel.
let max_x = (display_width as i32).saturating_sub(1).max(0);
let max_y = (display_height as i32).saturating_sub(1).max(0);
new_x = new_x.clamp(0, max_x);
new_y = new_y.clamp(0, max_y);
// Pass delta values for relative movement
// This is critical for browser Pointer Lock API support
// The delta fields (MOUSE_EVENT_DELTA_X/Y) are used by browsers
// to calculate movementX/Y in Pointer Lock mode
self.mouse_move_to_impl(new_x, new_y, Some((x, y)));
}
fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType {
@@ -473,6 +487,43 @@ impl Enigo {
}
}
/// Internal implementation for mouse movement with optional delta values.
///
/// The `delta` parameter is crucial for browser Pointer Lock API support.
/// When a browser enters Pointer Lock mode, it reads mouse delta values
/// (MOUSE_EVENT_DELTA_X/Y) directly from CGEvent to calculate movementX/Y.
/// Without setting these fields, the browser sees zero movement.
fn mouse_move_to_impl(&mut self, x: i32, y: i32, delta: Option<(i32, i32)>) {
let pressed = Self::pressed_buttons();
// Determine event type and corresponding mouse button based on pressed buttons.
// The CGMouseButton must match the event type for drag events.
let (event_type, button) = if pressed & 1 > 0 {
(CGEventType::LeftMouseDragged, CGMouseButton::Left)
} else if pressed & 2 > 0 {
(CGEventType::RightMouseDragged, CGMouseButton::Right)
} else if pressed & 4 > 0 {
(CGEventType::OtherMouseDragged, CGMouseButton::Center)
} else {
(CGEventType::MouseMoved, CGMouseButton::Left) // Button doesn't matter for MouseMoved
};
let dest = CGPoint::new(x as f64, y as f64);
if let Some(src) = self.event_source.as_ref() {
if let Ok(event) =
CGEvent::new_mouse_event(src.clone(), event_type, dest, button)
{
// Set delta fields for relative mouse movement
// This is essential for Pointer Lock API in browsers
if let Some((dx, dy)) = delta {
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
}
self.post(event, None);
}
}
}
/// Fetches the `(width, height)` in pixels of the main display
pub fn main_display_size() -> (usize, usize) {
let display_id = unsafe { CGMainDisplayID() };

View File

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

View File

@@ -1,3 +1,5 @@
#![allow(non_snake_case)]
use hbb_common::{bail, ResultType};
use std::{io, ptr::null_mut};
use winapi::{

View File

@@ -10,7 +10,7 @@ authors = ["Ram <quadrupleslap@gmail.com>"]
edition = "2018"
[features]
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing"]
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
mediacodec = ["ndk"]
linux-pkg-config = ["dep:pkg-config"]
hwcodec = ["dep:hwcodec"]
@@ -57,6 +57,7 @@ tracing = { version = "0.1", optional = true }
gstreamer = { version = "0.16", optional = true }
gstreamer-app = { version = "0.16", features = ["v1_10"], optional = true }
gstreamer-video = { version = "0.16", optional = true }
zbus = { version = "3.15", optional = true }
[dependencies.hwcodec]
git = "https://github.com/rustdesk-org/hwcodec"

View File

@@ -227,24 +227,12 @@ fn ffmpeg() {
*/
fn main() {
// in this crate, these are also valid configurations
println!("cargo:rustc-check-cfg=cfg(dxgi,quartz,x11)");
// there is problem with cfg(target_os) in build.rs, so use our workaround
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
// We check if is macos, because macos uses rust 1.8.1.
// `cargo::rustc-check-cfg` is new with Cargo 1.80.
// No need to run `cargo version` to get the version here, because:
// The following lines are used to suppress the lint warnings.
// warning: unexpected `cfg` condition name: `quartz`
if cfg!(target_os = "macos") {
if target_os != "ios" {
println!("cargo::rustc-check-cfg=cfg(android)");
println!("cargo::rustc-check-cfg=cfg(dxgi)");
println!("cargo::rustc-check-cfg=cfg(quartz)");
println!("cargo::rustc-check-cfg=cfg(x11)");
// ^^^^^^^^^^^^^^^^^^^^^^ new with Cargo 1.80
}
}
// note: all link symbol names in x86 (32-bit) are prefixed wth "_".
// run "rustup show" to show current default toolchain, if it is stable-x86-pc-windows-msvc,
// please install x64 toolchain by "rustup toolchain install stable-x86_64-pc-windows-msvc",

View File

@@ -22,6 +22,7 @@ use std::time::{Duration, Instant};
lazy_static! {
static ref JVM: RwLock<Option<JavaVM>> = RwLock::new(None);
static ref MAIN_SERVICE_CTX: RwLock<Option<GlobalRef>> = RwLock::new(None); // MainService -> video service / audio service / info
static ref APPLICATION_CONTEXT: RwLock<Option<GlobalRef>> = RwLock::new(None);
static ref VIDEO_RAW: Mutex<FrameRaw> = Mutex::new(FrameRaw::new("video", MAX_VIDEO_FRAME_TIMEOUT));
static ref AUDIO_RAW: Mutex<FrameRaw> = Mutex::new(FrameRaw::new("audio", MAX_AUDIO_FRAME_TIMEOUT));
static ref NDK_CONTEXT_INITED: Mutex<bool> = Default::default();
@@ -462,6 +463,23 @@ fn init_ndk_context(java_vm: *mut c_void, context_jobject: *mut c_void) {
*lock = true;
}
fn try_init_rustls_platform_verifier(env: &mut JNIEnv, context_jobject: *mut c_void) {
use hbb_common::config::ANDROID_RUSTLS_PLATFORM_VERIFIER_INITIALIZED as INITIALIZED;
use std::sync::atomic::Ordering;
let initialized = INITIALIZED.load(Ordering::Relaxed);
if !initialized {
let ctx_for_rustls = unsafe { JObject::from_raw(context_jobject as jni::sys::jobject) };
if let Err(e) =
hbb_common::rustls_platform_verifier::android::init_hosted(env, ctx_for_rustls)
{
log::error!("Failed to initialize rustls-platform-verifier: {:?}", e);
} else {
INITIALIZED.store(true, Ordering::Relaxed);
log::info!("rustls-platform-verifier initialized successfully");
}
}
}
// https://cjycode.com/flutter_rust_bridge/guides/how-to/ndk-init
#[no_mangle]
pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, res: *mut std::os::raw::c_void) -> jni::sys::jint {
@@ -471,3 +489,23 @@ pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, res: *mut std::os::raw::c_void) ->
}
jni::JNIVersion::V6.into()
}
#[no_mangle]
pub extern "system" fn Java_ffi_FFI_onAppStart(mut env: JNIEnv, _class: JClass, ctx: JObject) {
if ctx.is_null() {
log::error!("application context is null");
return;
}
if APPLICATION_CONTEXT.read().unwrap().is_some() {
log::info!("application context already initialized");
return;
}
if let Ok(jvm) = env.get_java_vm() {
if let Ok(context) = env.new_global_ref(ctx) {
let java_vm = jvm.get_java_vm_pointer() as *mut c_void;
let context_jobject = context.as_obj().as_raw() as *mut c_void;
*APPLICATION_CONTEXT.write().unwrap() = Some(context);
try_init_rustls_platform_verifier(&mut env, context_jobject);
}
}
}

View File

@@ -287,7 +287,7 @@ impl EncoderApi for AomEncoder {
}
impl AomEncoder {
pub fn encode(&mut self, ms: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames> {
pub fn encode<'a>(&'a mut self, ms: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames<'a>> {
let bpp = if self.i444 { 24 } else { 12 };
if data.len() < self.width * self.height * bpp / 8 {
return Err(Error::FailedCall("len not enough".to_string()));
@@ -461,7 +461,7 @@ impl AomDecoder {
Ok(Self { ctx })
}
pub fn decode(&mut self, data: &[u8]) -> Result<DecodeFrames> {
pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result<DecodeFrames<'a>> {
call_aom!(aom_codec_decode(
&mut self.ctx,
data.as_ptr(),
@@ -476,7 +476,7 @@ impl AomDecoder {
}
/// Notify the decoder to return any pending frame
pub fn flush(&mut self) -> Result<DecodeFrames> {
pub fn flush<'a>(&'a mut self) -> Result<DecodeFrames<'a>> {
call_aom!(aom_codec_decode(
&mut self.ctx,
ptr::null(),

View File

@@ -364,7 +364,7 @@ impl HwRamDecoder {
}
}
}
pub fn decode(&mut self, data: &[u8]) -> ResultType<Vec<HwRamDecoderImage>> {
pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType<Vec<HwRamDecoderImage<'a>>> {
match self.decoder.decode(data) {
Ok(v) => Ok(v.iter().map(|f| HwRamDecoderImage { frame: f }).collect()),
Err(e) => Err(anyhow!(e)),

View File

@@ -88,6 +88,27 @@ impl Display {
}
}
pub fn scale(&self) -> f64 {
match self {
Display::X11(_d) => 1.0,
Display::WAYLAND(d) => d.scale(),
}
}
pub fn logical_width(&self) -> usize {
match self {
Display::X11(d) => d.width(),
Display::WAYLAND(d) => d.logical_width(),
}
}
pub fn logical_height(&self) -> usize {
match self {
Display::X11(d) => d.height(),
Display::WAYLAND(d) => d.logical_height(),
}
}
pub fn origin(&self) -> (i32, i32) {
match self {
Display::X11(d) => d.origin(),

View File

@@ -3,6 +3,7 @@
#![allow(non_upper_case_globals)]
#![allow(improper_ctypes)]
#![allow(dead_code)]
#![allow(unused_imports)]
impl Default for vpx_codec_enc_cfg {
fn default() -> Self {

View File

@@ -231,7 +231,7 @@ impl EncoderApi for VpxEncoder {
}
impl VpxEncoder {
pub fn encode(&mut self, pts: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames> {
pub fn encode<'a>(&'a mut self, pts: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames<'a>> {
let bpp = if self.i444 { 24 } else { 12 };
if data.len() < self.width * self.height * bpp / 8 {
return Err(Error::FailedCall("len not enough".to_string()));
@@ -268,7 +268,7 @@ impl VpxEncoder {
}
/// Notify the encoder to return any pending packets
pub fn flush(&mut self) -> Result<EncodeFrames> {
pub fn flush<'a>(&'a mut self) -> Result<EncodeFrames<'a>> {
call_vpx!(vpx_codec_encode(
&mut self.ctx,
ptr::null(),
@@ -473,7 +473,7 @@ impl VpxDecoder {
/// The `data` slice is sent to the decoder
///
/// It matches a call to `vpx_codec_decode`.
pub fn decode(&mut self, data: &[u8]) -> Result<DecodeFrames> {
pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result<DecodeFrames<'a>> {
call_vpx!(vpx_codec_decode(
&mut self.ctx,
data.as_ptr(),
@@ -489,7 +489,7 @@ impl VpxDecoder {
}
/// Notify the decoder to return any pending frame
pub fn flush(&mut self) -> Result<DecodeFrames> {
pub fn flush<'a>(&'a mut self) -> Result<DecodeFrames<'a>> {
call_vpx!(vpx_codec_decode(
&mut self.ctx,
ptr::null(),

View File

@@ -367,7 +367,7 @@ impl VRamDecoder {
}
}
}
pub fn decode(&mut self, data: &[u8]) -> ResultType<Vec<VRamDecoderImage>> {
pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType<Vec<VRamDecoderImage<'a>>> {
match self.decoder.decode(data) {
Ok(v) => Ok(v.iter().map(|f| VRamDecoderImage { frame: f }).collect()),
Err(e) => Err(anyhow!(e)),

View File

@@ -8,7 +8,6 @@ use super::x11::PixelBuffer;
pub struct Capturer(Display, Box<dyn Recorder>, Vec<u8>);
lazy_static::lazy_static! {
static ref MAP_ERR: RwLock<Option<fn(err: String)-> io::Error>> = Default::default();
}
@@ -61,7 +60,7 @@ impl TraitCapturer for Capturer {
}
}
pub struct Display(pipewire::PipeWireCapturable);
pub struct Display(pub(crate) pipewire::PipeWireCapturable);
impl Display {
pub fn primary() -> io::Result<Display> {
@@ -81,11 +80,35 @@ impl Display {
}
pub fn width(&self) -> usize {
self.0.size.0
self.physical_width()
}
pub fn height(&self) -> usize {
self.0.size.1
self.physical_height()
}
pub fn physical_width(&self) -> usize {
self.0.physical_size.0
}
pub fn physical_height(&self) -> usize {
self.0.physical_size.1
}
pub fn logical_width(&self) -> usize {
self.0.logical_size.0
}
pub fn logical_height(&self) -> usize {
self.0.logical_size.1
}
pub fn scale(&self) -> f64 {
if self.logical_width() == 0 {
1.0
} else {
self.physical_width() as f64 / self.logical_width() as f64
}
}
pub fn origin(&self) -> (i32, i32) {
@@ -97,7 +120,7 @@ impl Display {
}
pub fn is_primary(&self) -> bool {
false
self.0.primary
}
pub fn name(&self) -> String {

View File

@@ -1,4 +1,6 @@
// logic from webrtc -- https://github.com/shiguredo/libwebrtc/blob/main/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
#![allow(non_snake_case)]
use lazy_static;
use std::{
ffi::CString,

View File

@@ -1,5 +1,6 @@
pub mod capturable;
pub mod pipewire;
pub mod display;
mod screencast_portal;
mod request_portal;
pub mod remote_desktop_portal;

View File

@@ -0,0 +1,256 @@
use hbb_common::regex::Regex;
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::{
process::{Command, Output, Stdio},
sync::Arc,
time::{Duration, Instant},
};
use tracing::warn;
use hbb_common::platform::linux::{get_wayland_displays, WaylandDisplayInfo};
lazy_static! {
static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = Mutex::new(None);
}
const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
pub struct Displays {
pub primary: usize,
pub displays: Vec<WaylandDisplayInfo>,
}
// We need this helper to run commands with a timeout, as some commands may hang.
// `kscreen-doctor -o` is known to hang when:
// 1. On Archlinux, Both GNOME and KDE Plasma are installed.
// 2. Run this command in a GNOME session.
fn run_with_timeout(
program: &str,
args: &[&str],
timeout: Duration,
label: &str,
) -> Option<Output> {
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let start = Instant::now();
loop {
if let Ok(Some(_)) = child.try_wait() {
break;
}
if start.elapsed() >= timeout {
warn!("{} command timed out after {:?}", label, timeout);
if let Err(e) = child.kill() {
warn!("Failed to kill child process for '{}': {}", label, e);
}
if let Err(e) = child.wait() {
warn!("Failed to wait for child process for '{}': {}", label, e);
}
return None;
}
std::thread::sleep(Duration::from_millis(30));
}
match child.wait_with_output() {
Ok(output) => {
if !output.status.success() {
warn!("{} command failed with status: {}", label, output.status);
return None;
}
Some(output)
}
Err(_) => None,
}
}
// There are some limitations with xrandr method:
// 1. It only works when XWayland is running.
// 2. The distro may not have xrandr installed by default.
// 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma.
fn try_xrandr_primary() -> Option<String> {
let output = Command::new("xrandr").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("primary") && line.contains("connected") {
if let Some(name) = line.split_whitespace().next() {
return Some(name.to_string());
}
}
}
None
}
fn try_kscreen_primary() -> Option<String> {
if !hbb_common::platform::linux::is_kde_session() {
return None;
}
let output = run_with_timeout(
"kscreen-doctor",
&["-o"],
COMMAND_TIMEOUT,
"kscreen-doctor -o",
)?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
// Remove ANSI color codes
let re_ansi = Regex::new(r"\x1b\[[0-9;]*m").ok()?;
let clean_text = re_ansi.replace_all(&text, "");
// Split the text into blocks, each starting with "Output:".
// The first element of the split will be empty, so we skip it.
for block in clean_text.split("Output:").skip(1) {
// Check if this block describes the primary monitor.
if block.contains("priority 1") {
// The monitor name is the second piece of text in the block, after the ID.
// e.g., " 1 eDP-1 enabled..." -> "eDP-1"
if let Some(name) = block.split_whitespace().nth(1) {
return Some(name.to_string());
}
}
}
None
}
fn try_gdbus_primary() -> Option<String> {
let output = run_with_timeout(
"gdbus",
&[
"call",
"--session",
"--dest",
"org.gnome.Mutter.DisplayConfig",
"--object-path",
"/org/gnome/Mutter/DisplayConfig",
"--method",
"org.gnome.Mutter.DisplayConfig.GetCurrentState",
],
COMMAND_TIMEOUT,
"gdbus DisplayConfig.GetCurrentState",
)?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
// Match logical monitor entries with primary=true
// Pattern: (x, y, scale, transform, true, [('connector-name', ...), ...], ...)
// Use regex to find entries where 5th field is true, then extract connector name
// Example matched text: "(0, 0, 1.5, 0, true, [('HDMI-1', 'MHH', 'Monitor', '0x00000000')], ...)"
let re = Regex::new(r"\([^()]*,\s*true,\s*\[\('([^']+)'").ok()?;
if let Some(captures) = re.captures(&text) {
return captures.get(1).map(|m| m.as_str().to_string());
}
None
}
fn get_primary_monitor() -> Option<String> {
try_xrandr_primary()
.or_else(try_kscreen_primary)
.or_else(try_gdbus_primary)
}
pub fn get_displays() -> Arc<Displays> {
let mut lock = DISPLAYS.lock().unwrap();
match lock.as_ref() {
Some(displays) => displays.clone(),
None => match get_wayland_displays() {
Ok(displays) => {
let mut primary_index = None;
if let Some(name) = get_primary_monitor() {
for (i, display) in displays.iter().enumerate() {
if display.name == name {
primary_index = Some(i);
break;
}
}
};
if primary_index.is_none() {
for (i, display) in displays.iter().enumerate() {
if display.x == 0 && display.y == 0 {
primary_index = Some(i);
break;
}
}
}
let displays = Arc::new(Displays {
primary: primary_index.unwrap_or(0),
displays,
});
*lock = Some(displays.clone());
displays
}
Err(err) => {
warn!("Failed to get wayland displays: {}", err);
Arc::new(Displays {
primary: 0,
displays: Vec::new(),
})
}
},
}
}
#[inline]
pub fn clear_wayland_displays_cache() {
let _ = DISPLAYS.lock().unwrap().take();
}
// Return (min_x, max_x, min_y, max_y)
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
let wayland_displays = get_displays();
let displays = &wayland_displays.displays;
if displays.is_empty() {
return None;
}
// For compatibility, if only one display, we use the physical size for `uinput`.
// Otherwise, we use the logical size for `uinput`.
if displays.len() == 1 {
let d = &displays[0];
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
}
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for d in displays.iter() {
min_x = min_x.min(d.x);
min_y = min_y.min(d.y);
let size = if let Some(logical_size) = d.logical_size {
logical_size
} else {
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
// This may occur if the Wayland compositor does not provide logical size information,
// or if display information is incomplete. We fall back to physical size, which provides
// usable dimensions, but may not always be correct depending on compositor behavior.
warn!(
"Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).",
d.x, d.y, d.width, d.height
);
(d.width, d.height)
};
max_x = max_x.max(d.x + size.0);
max_y = max_y.max(d.y + size.1);
}
Some((min_x, max_x, min_y, max_y))
}

View File

@@ -2,9 +2,12 @@ use std::collections::HashMap;
use std::error::Error;
use std::os::unix::io::AsRawFd;
use std::process::Command;
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::sync::{
atomic::{AtomicBool, AtomicU8, Ordering},
Arc, Mutex,
};
use std::time::Duration;
use tracing::{debug, trace, warn};
use tracing::{debug, error, trace, warn};
use dbus::{
arg::{OwnedFd, PropMap, RefArg, Variant},
@@ -17,23 +20,58 @@ use gstreamer as gst;
use gstreamer::prelude::*;
use gstreamer_app::AppSink;
use hbb_common::config;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use hbb_common::{bail, config, platform::linux::CMD_SH, serde_json, tokio, ResultType};
use super::capturable::PixelProvider;
use super::capturable::{Capturable, Recorder};
use super::display::{clear_wayland_displays_cache, get_displays, Displays};
use super::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal;
use super::request_portal::OrgFreedesktopPortalRequestResponse;
use super::screencast_portal::OrgFreedesktopPortalScreenCast as screencast_portal;
use hbb_common::platform::linux::CMD_SH;
use lazy_static::lazy_static;
lazy_static! {
pub static ref RDP_SESSION_INFO: Mutex<Option<RdpSessionInfo>> = Mutex::new(None);
}
#[derive(Serialize, Deserialize)]
// For KDE Plasma only, because GNOME provides position info.
struct PipewireDisplayOffsetCache {
// We need to compare the displays, because:
// 1. On Archlinux KDE Plasma
// 2. One display, and connect, remember share choice.
// 3. Plug in another monitor.
// 4. The portal will reuse the restore token, no new share choice dialog, but the share screen is different.
// The controlling side will see the new monitor.
// All displays as one string for easy comparison
// name1-x1-y1-width1-height1;name2-x2-y2-width2-height2;...
display_key: String,
restore_token: String,
offsets: Vec<(i32, i32)>,
}
// KDE Plasma may not provide position info
static HAS_POSITION_ATTR: AtomicBool = AtomicBool::new(false);
static IS_SERVER_RUNNING: AtomicU8 = AtomicU8::new(0); // 0: uninitialized, 1:true, 2: false
impl PipewireDisplayOffsetCache {
fn displays_to_key(displays: &Arc<Displays>) -> String {
displays
.displays
.iter()
.map(|d| format!("{}-{}-{}-{}-{}", d.name, d.x, d.y, d.width, d.height))
.collect::<Vec<String>>()
.join(";")
}
}
#[inline]
pub fn close_session() {
let _ = RDP_SESSION_INFO.lock().unwrap().take();
clear_wayland_displays_cache();
HAS_POSITION_ATTR.store(false, Ordering::SeqCst);
}
#[inline]
@@ -52,6 +90,8 @@ pub fn try_close_session() {
}
if close {
*rdp_info = None;
clear_wayland_displays_cache();
HAS_POSITION_ATTR.store(false, Ordering::SeqCst);
}
}
@@ -75,6 +115,10 @@ impl PwStreamInfo {
pub fn get_size(&self) -> (usize, usize) {
self.size
}
pub fn get_position(&self) -> (i32, i32) {
self.position
}
}
#[derive(Debug)]
@@ -108,8 +152,10 @@ pub struct PipeWireCapturable {
fd: OwnedFd,
path: u64,
source_type: u64,
pub primary: bool,
pub position: (i32, i32),
pub size: (usize, usize),
pub logical_size: (usize, usize),
pub physical_size: (usize, usize),
}
impl PipeWireCapturable {
@@ -117,27 +163,31 @@ impl PipeWireCapturable {
conn: Arc<SyncConnection>,
fd: OwnedFd,
resolution: Arc<Mutex<Option<(usize, usize)>>>,
stream: PwStreamInfo,
stream: &PwStreamInfo,
) -> Self {
// alternative to get screen resolution as stream.size is not always correct ex: on fractional scaling
// https://github.com/rustdesk/rustdesk/issues/6116#issuecomment-1817724244
let size = get_res(Self {
let physical_size = get_res(Self {
dbus_conn: conn.clone(),
fd: fd.clone(),
path: stream.path,
source_type: stream.source_type,
primary: false,
position: stream.position,
size: stream.size,
logical_size: stream.size,
physical_size: (0, 0),
})
.unwrap_or(stream.size);
*resolution.lock().unwrap() = Some(size);
*resolution.lock().unwrap() = Some(physical_size);
Self {
dbus_conn: conn,
fd,
path: stream.path,
source_type: stream.source_type,
primary: false,
position: stream.position,
size,
logical_size: stream.size,
physical_size,
}
}
}
@@ -214,7 +264,7 @@ pub struct PipeWireRecorder {
}
impl PipeWireRecorder {
pub fn new(capturable: PipeWireCapturable) -> Result<Self, Box<dyn Error>> {
pub fn new(capturable: PipeWireCapturable) -> ResultType<Self> {
let pipeline = gst::Pipeline::new(None);
let src = gst::ElementFactory::make("pipewiresrc", None)?;
@@ -247,7 +297,40 @@ impl PipeWireRecorder {
));
appsink.set_caps(Some(&caps));
// [Workaround]
// Crash may occur if there are multiple pipelines started at the same time.
// `pipeline.get_state()` can significantly reduce the probability of crashes,
// but cannot completely resolve this issue.
// Adding a short sleep period can also reduce the probability of crashes.
debug!(
"[gstreamer] Setting pipeline {} to PLAYING state...",
capturable.fd.as_raw_fd()
);
pipeline.set_state(gst::State::Playing)?;
// If `is_server_running()` is false, it means using remote_desktop_portal,
// which does not use multiple streams, so no need to wait for state change.
if is_server_running() {
// Wait for the state change to actually complete before proceeding.
// The 2000ms timeout for pipeline state change was chosen based on empirical testing.
let state_change = pipeline.get_state(gst::ClockTime::from_mseconds(2000));
match state_change {
(Ok(_), gst::State::Playing, _) => {
debug!(
"[gstreamer] Pipeline {} state confirmed as PLAYING.",
capturable.fd.as_raw_fd()
);
}
(result, state, pending) => {
warn!(
"[gstreamer] Pipeline {} state change incomplete: result={:?}, state={:?}, pending={:?}",
capturable.fd.as_raw_fd(), result, state, pending
);
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
Ok(Self {
pipeline,
appsink,
@@ -366,6 +449,8 @@ impl Drop for PipeWireRecorder {
if let Err(err) = self.pipeline.set_state(gst::State::Null) {
warn!("Failed to stop GStreamer pipeline: {}.", err);
}
// Wait for state change to complete to avoid races during PipeWire teardown.
let _ = self.pipeline.get_state(gst::ClockTime::from_mseconds(2000));
}
}
@@ -396,18 +481,18 @@ where
0 => {}
1 => {
warn!("DBus response: User cancelled interaction.");
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
failure_out.store(true, Ordering::SeqCst);
return true;
}
c => {
warn!("DBus response: Unknown error, code: {}.", c);
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
failure_out.store(true, Ordering::SeqCst);
return true;
}
}
if let Err(err) = f(r, c, m) {
warn!("Error requesting screen capture via dbus: {}", err);
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
failure_out.store(true, Ordering::SeqCst);
}
true
})
@@ -488,6 +573,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec<P
if v.len() == 2 {
info.position.0 = v[0] as _;
info.position.1 = v[1] as _;
HAS_POSITION_ATTR.store(true, Ordering::SeqCst);
}
}
}
@@ -502,6 +588,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec<P
static mut INIT: bool = false;
const RESTORE_TOKEN: &str = "restore_token";
const RESTORE_TOKEN_CONF_KEY: &str = "wayland-restore-token";
const PIPEWIRE_DISPLAY_OFFSET_CONF_KEY: &str = "wayland-pipewire-display-offset";
pub fn get_available_cursor_modes() -> Result<u32, dbus::Error> {
let conn = SyncConnection::new_session()?;
@@ -510,16 +597,15 @@ pub fn get_available_cursor_modes() -> Result<u32, dbus::Error> {
}
// mostly inspired by https://gitlab.gnome.org/-/snippets/39
pub fn request_remote_desktop() -> Result<
(
SyncConnection,
OwnedFd,
Vec<PwStreamInfo>,
dbus::Path<'static>,
bool,
),
Box<dyn Error>,
> {
pub fn request_remote_desktop(
capture_cursor: bool,
) -> ResultType<(
SyncConnection,
OwnedFd,
Vec<PwStreamInfo>,
dbus::Path<'static>,
bool,
)> {
unsafe {
if !INIT {
gstreamer::init()?;
@@ -574,6 +660,7 @@ pub fn request_remote_desktop() -> Result<
session.clone(),
failure.clone(),
is_support_restore_token,
capture_cursor,
),
failure_res.clone(),
)?;
@@ -586,7 +673,7 @@ pub fn request_remote_desktop() -> Result<
break;
}
if failure_res.load(std::sync::atomic::Ordering::Relaxed) {
if failure_res.load(Ordering::SeqCst) {
break;
}
}
@@ -607,9 +694,7 @@ pub fn request_remote_desktop() -> Result<
}
}
}
Err(Box::new(DBusError(
"Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.".into()
)))
bail!("Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.")
}
fn on_create_session_response(
@@ -618,6 +703,7 @@ fn on_create_session_response(
session: Arc<Mutex<Option<dbus::Path<'static>>>>,
failure: Arc<AtomicBool>,
is_support_restore_token: bool,
capture_cursor: bool,
) -> impl Fn(
OrgFreedesktopPortalRequestResponse,
&SyncConnection,
@@ -666,6 +752,14 @@ fn on_create_session_response(
}
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
if capture_cursor {
get_available_cursor_modes().ok().map(|modes| {
if modes & 0x2 != 0 {
args.insert("cursor_mode".to_string(), Variant(Box::new(2u32)));
}
});
}
let path = portal.select_sources(ses.clone(), args)?;
handle_response(
c,
@@ -838,7 +932,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
};
if rdp_connection.is_none() {
let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop()?;
let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop(false)?;
let conn = Arc::new(conn);
let rdp_info = RdpSessionInfo {
@@ -852,7 +946,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
*rdp_connection = Some(rdp_info);
}
let rdp_info = match rdp_connection.as_ref() {
let rdp_info = match rdp_connection.as_mut() {
Some(res) => res,
None => {
return Err(Box::new(DBusError("RDP response is None.".into())));
@@ -861,8 +955,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
Ok(rdp_info
.streams
.clone()
.into_iter()
.iter()
.map(|s| {
PipeWireCapturable::new(
rdp_info.conn.clone(),
@@ -883,7 +976,12 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
//
// `screencast_portal` supports restore_token and persist_mode if the version is greater than or equal to 4.
// `remote_desktop_portal` does not support restore_token and persist_mode.
fn is_server_running() -> bool {
pub(crate) fn is_server_running() -> bool {
let v = IS_SERVER_RUNNING.load(Ordering::SeqCst);
if v > 0 {
return v == 1;
}
let app_name = config::APP_NAME.read().unwrap().clone().to_lowercase();
let output = match Command::new(CMD_SH.as_str())
.arg("-c")
@@ -898,5 +996,533 @@ fn is_server_running() -> bool {
let output_str = String::from_utf8_lossy(&output.stdout);
let is_running = output_str.contains(&format!("{} --server", app_name));
IS_SERVER_RUNNING.store(if is_running { 1 } else { 2 }, Ordering::SeqCst);
is_running
}
// The logical size reported by portal may be different from the size reported by `get_displays()`.
// So we need to use the workaround here.
// 1. openSUSE, KDE Plasma
// 2. Kubuntu 24.04 TLS, after running `sudo apt install plasma-workspace-wayland`
// Maybe it's a bug, and we can remove this workaround in the future.
pub fn try_fix_logical_size(shared_displays: &mut Vec<crate::Display>) {
if !is_server_running() {
return;
}
let wayland_displays = get_displays();
if wayland_displays.displays.is_empty() {
return;
}
for sd in shared_displays.iter_mut() {
if let crate::Display::WAYLAND(d) = sd {
let capturable = &mut d.0;
for wd in wayland_displays.displays.iter() {
if capturable.position.0 == wd.x && capturable.position.1 == wd.y {
if let Some(logical_size) = wd.logical_size {
if capturable.physical_size.0 != wd.width as usize
|| capturable.physical_size.1 != wd.height as usize
{
// If "Full Workspace" is selected in the portal dialog,
// the physical size reported by portal may not match the display info.
debug!(
"Physical size of capturable ({:?}) does not match display info: ({:?}) - ({:?}). Skipping logical size fix.",
capturable.position,
capturable.physical_size,
(wd.width as usize, wd.height as usize)
);
break;
}
if capturable.logical_size.0 != logical_size.0 as usize
|| capturable.logical_size.1 != logical_size.1 as usize
{
warn!(
"Fixing logical size of capturable from {:?} to {:?} based on display info {:?}.",
capturable.logical_size,
logical_size,
wd
);
capturable.logical_size =
(logical_size.0 as usize, logical_size.1 as usize);
}
}
break;
}
}
}
}
}
pub fn fill_displays(
mouse_move_to: impl Fn(i32, i32),
get_cursor_pos: fn() -> Option<(i32, i32)>,
shared_displays: &mut Vec<crate::Display>,
) -> ResultType<()> {
if !is_server_running() {
return Ok(());
}
let mut rdp_connection = RDP_SESSION_INFO.lock().unwrap();
let rdp_info = match rdp_connection.as_mut() {
Some(res) => res,
None => {
// Unreachable
bail!("RDP session info is None when filling display positions.");
}
};
let all_displays = get_displays();
if !HAS_POSITION_ATTR.load(Ordering::SeqCst) {
if all_displays.displays.len() > 1 {
debug!("Multiple Wayland displays detected, adjusting stream positions accordingly.");
try_fill_positions(
mouse_move_to,
get_cursor_pos,
&all_displays,
shared_displays,
&mut rdp_info.streams,
)?;
}
HAS_POSITION_ATTR.store(true, Ordering::SeqCst);
}
if all_displays.displays.len() > 1 {
sort_streams(&all_displays, shared_displays, &mut rdp_info.streams);
}
shared_displays.iter_mut().next().map(|d| {
if let crate::Display::WAYLAND(d) = d {
d.0.primary = true;
}
});
Ok(())
}
fn try_fill_positions(
mouse_move_to: impl Fn(i32, i32),
get_cursor_pos: fn() -> Option<(i32, i32)>,
displays: &Arc<Displays>,
shared_displays: &mut Vec<crate::Display>,
streams: &mut Vec<PwStreamInfo>,
) -> ResultType<()> {
let pipewire_display_offset = config::LocalConfig::get_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY);
if !pipewire_display_offset.is_empty() {
if try_fill_positions_from_cache(
pipewire_display_offset,
displays,
shared_displays,
streams,
) {
return Ok(());
}
config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), "".to_owned());
}
let mut multi_matched_indices = Vec::new();
for (i, sd) in shared_displays.iter_mut().enumerate() {
if let crate::Display::WAYLAND(d) = sd {
let capturable = &mut d.0;
let mut match_count = 0;
for wd in displays.displays.iter() {
if capturable.physical_size.0 == wd.width as usize
&& capturable.physical_size.1 == wd.height as usize
{
capturable.position = (wd.x, wd.y);
if let Some(pw_stream) = streams.get_mut(i) {
pw_stream.position = (wd.x, wd.y);
}
match_count += 1;
}
}
if match_count == 0 {
warn!(
"No matching display found for capturable with size {:?}.",
capturable.physical_size
);
} else if match_count > 1 {
multi_matched_indices.push(i);
}
}
}
if !multi_matched_indices.is_empty() {
fill_multi_matched_positions(
mouse_move_to,
get_cursor_pos,
displays,
shared_displays,
streams,
multi_matched_indices,
)?;
}
save_positions_to_cache(displays, shared_displays);
Ok(())
}
fn try_fill_positions_from_cache(
cache_str: String,
displays: &Arc<Displays>,
shared_displays: &mut Vec<crate::Display>,
streams: &mut Vec<PwStreamInfo>,
) -> bool {
let Ok(cache) = serde_json::from_str::<PipewireDisplayOffsetCache>(&cache_str) else {
return false;
};
if cache.offsets.len() != shared_displays.len() {
return false;
}
let display_key = PipewireDisplayOffsetCache::displays_to_key(displays);
if cache.display_key != display_key {
return false;
}
let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY);
if cache.restore_token != restore_token {
return false;
}
for (i, sd) in shared_displays.iter_mut().enumerate() {
if let crate::Display::WAYLAND(d) = sd {
let capturable = &mut d.0;
if let Some((x_off, y_off)) = cache.offsets.get(i) {
capturable.position = (*x_off, *y_off);
if let Some(pw_stream) = streams.get_mut(i) {
pw_stream.position = (*x_off, *y_off);
}
}
}
}
true
}
fn save_positions_to_cache(displays: &Arc<Displays>, shared_displays: &Vec<crate::Display>) {
let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY);
if restore_token.is_empty() {
return;
}
let mut offsets = Vec::new();
for sd in shared_displays.iter() {
if let crate::Display::WAYLAND(d) = sd {
let capturable = &d.0;
offsets.push((capturable.position.0, capturable.position.1));
}
}
let display_key = PipewireDisplayOffsetCache::displays_to_key(displays);
let cache = PipewireDisplayOffsetCache {
display_key,
restore_token,
offsets,
};
if let Ok(s) = serde_json::to_string(&cache) {
config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), s);
}
}
fn compare_left_up_corner(w: usize, d1: &[u8], d2: &[u8]) -> bool {
if w == 0 {
return false;
}
if d1.len() != d2.len() {
return false;
}
let bpp = 4; // BGR0/RGB0
let stride = w.saturating_mul(bpp);
if stride == 0 || d1.len() < stride || d2.len() < stride {
return false;
}
let h = d1.len() / stride;
if h == 0 {
return false;
}
let roi_w = std::cmp::min(36, w);
let roi_h = std::cmp::min(36, h);
let mut diff_px = 0usize;
let total_px = roi_w * roi_h;
// Minimum number of differing pixels required to consider images different.
const MIN_DIFF_PIXELS: usize = 8;
// Divisor for threshold calculation: allows up to 1/8 of ROI pixels to differ before returning true.
const DIFF_THRESHOLD_DIVISOR: usize = 8;
let threshold = std::cmp::max(MIN_DIFF_PIXELS, total_px / DIFF_THRESHOLD_DIVISOR);
for y in 0..roi_h {
let row_off = y * stride;
for x in 0..roi_w {
let i = row_off + x * bpp;
let a = &d1[i..i + bpp];
let b = &d2[i..i + bpp];
if a != b {
diff_px += 1;
if diff_px >= threshold {
return true;
}
}
}
}
false
}
fn fill_multi_matched_positions(
mouse_move_to: impl Fn(i32, i32),
get_cursor_pos: fn() -> Option<(i32, i32)>,
displays: &Arc<Displays>,
shared_displays: &mut Vec<crate::Display>,
streams: &mut Vec<PwStreamInfo>,
multi_matched_indices: Vec<usize>,
) -> ResultType<()> {
debug!(
"Multiple capturables ({:?}) match the same display size, attempting to disambiguate positions.",
&multi_matched_indices);
if multi_matched_indices.is_empty() {
return Ok(());
}
let is_support_embeded_cursor = get_available_cursor_modes()
.ok()
.map(|modes| modes & 0x2 != 0)
.unwrap_or(false);
if is_support_embeded_cursor {
fill_multi_matched_positions_cursor(
mouse_move_to,
get_cursor_pos,
displays,
shared_displays,
streams,
multi_matched_indices,
)?;
}
Ok(())
}
fn mouse_move_to_(
mouse_move_to: &impl Fn(i32, i32),
get_cursor_pos: fn() -> Option<(i32, i32)>,
x: i32,
y: i32,
) {
const MOVE_MOUSE_TIMEOUT: Duration = Duration::from_millis(150);
let start = std::time::Instant::now();
while start.elapsed() < MOVE_MOUSE_TIMEOUT {
mouse_move_to(x, y);
std::thread::sleep(Duration::from_millis(20));
if let Some((x1, y1)) = get_cursor_pos() {
if x1 == x && y1 == y {
return;
}
}
}
warn!(
"Failed to move mouse to ({}, {}) within timeout: {:?}.",
x, y, &MOVE_MOUSE_TIMEOUT
);
}
fn fill_multi_matched_positions_cursor(
mouse_move_to: impl Fn(i32, i32),
get_cursor_pos: fn() -> Option<(i32, i32)>,
displays: &Arc<Displays>,
shared_displays: &mut Vec<crate::Display>,
streams: &mut Vec<PwStreamInfo>,
multi_matched_indices: Vec<usize>,
) -> ResultType<()> {
// This creates a new remote desktop session for cursor-based position detection.
// The session is temporary, used only for disambiguation, and is dropped after detection completes.
let (conn, fd, streams_with_cursor, _session, _is_support_restore_token) =
request_remote_desktop(true)?;
let conn = Arc::new(conn);
let mut matched_indices = Vec::new();
const CAPTURE_TIMEOUT_MS: u64 = 1_000;
for idx in multi_matched_indices {
match (
shared_displays.get_mut(idx),
streams.get_mut(idx),
streams_with_cursor.get(idx),
) {
(Some(crate::Display::WAYLAND(d)), Some(pw_stream), Some(pw_stream_with_cursor)) => {
// Check if only one display matches the size
let mut match_count = 0;
for (i, wd) in displays.displays.iter().enumerate() {
if matched_indices.contains(&i) {
continue;
}
if d.0.physical_size.0 == wd.width as usize
&& d.0.physical_size.1 == wd.height as usize
{
match_count += 1;
}
}
if match_count == 0 {
error!(
"No matching display found for capturable with size {:?}.",
d.0.physical_size
);
continue;
}
if match_count == 1 {
for (i, wd) in displays.displays.iter().enumerate() {
if matched_indices.contains(&i) {
continue;
}
if d.0.physical_size.0 == wd.width as usize
&& d.0.physical_size.1 == wd.height as usize
{
d.0.position = (wd.x, wd.y);
pw_stream.position = (wd.x, wd.y);
matched_indices.push(i);
debug!(
"Disambiguated position for capturable with size {:?} to ({}, {}).",
d.0.physical_size, wd.x, wd.y
);
break;
}
}
continue;
}
// Move the mouse to a neutral position first,
// to avoid interference from previous position.
mouse_move_to_(&mouse_move_to, get_cursor_pos, 300, 300);
let mut rec = PipeWireRecorder::new(PipeWireCapturable {
dbus_conn: conn.clone(),
fd: fd.clone(),
path: pw_stream_with_cursor.path,
source_type: pw_stream_with_cursor.source_type,
primary: false,
position: pw_stream_with_cursor.position,
logical_size: pw_stream_with_cursor.size,
physical_size: (0, 0),
})?;
// Take first frame and copy owned buffer to avoid borrow across second capture
let (is_bgr, w, first_buf): (bool, usize, Vec<u8>) =
match rec.capture(CAPTURE_TIMEOUT_MS) {
Ok(PixelProvider::BGR0(w, _, data1)) => (true, w, data1.to_vec()),
Ok(PixelProvider::RGB0(w, _, data1)) => (false, w, data1.to_vec()),
Ok(_) => {
error!("Unexpected pixel format on first capture.");
continue;
}
Err(e) => {
error!(
"Failed to capture screen for position disambiguation: {}",
e
);
continue;
}
};
let matched_len = matched_indices.len();
for (i, wd) in displays.displays.iter().enumerate() {
if matched_indices.contains(&i) {
continue;
}
if wd.width as usize == d.0.physical_size.0
&& wd.height as usize == d.0.physical_size.1
{
mouse_move_to_(&mouse_move_to, get_cursor_pos, wd.x + 8, wd.y + 8);
rec.saved_raw_data.clear();
match rec.capture(CAPTURE_TIMEOUT_MS) {
Ok(PixelProvider::BGR0(_, _, data2)) if is_bgr => {
if compare_left_up_corner(w, &first_buf, data2) {
d.0.position = (wd.x, wd.y);
pw_stream.position = (wd.x, wd.y);
matched_indices.push(i);
debug!(
"Disambiguated position for capturable with size {:?} to ({}, {}).",
d.0.physical_size, wd.x, wd.y
);
break;
}
}
Ok(PixelProvider::RGB0(_, _, data2)) if !is_bgr => {
if compare_left_up_corner(w, &first_buf, data2) {
d.0.position = (wd.x, wd.y);
pw_stream.position = (wd.x, wd.y);
matched_indices.push(i);
debug!(
"Disambiguated position for capturable with size {:?} to ({}, {}).",
d.0.physical_size, wd.x, wd.y
);
break;
}
}
Ok(_) => {
// unreachable
error!("Pixel format changed between captures, cannot disambiguate position.");
}
Err(e) => {
error!(
"Failed to capture screen for position disambiguation: {}",
e
);
}
}
}
}
if matched_len == matched_indices.len() {
error!(
"Failed to disambiguate position for capturable with size {:?}.",
d.0.physical_size
);
}
}
_ => {}
}
}
Ok(())
}
fn sort_streams(
displays: &Arc<Displays>,
shared_displays: &mut Vec<crate::Display>,
streams: &mut Vec<PwStreamInfo>,
) {
if streams.is_empty() {
// unreachable
error!("No streams available to sort.");
return;
}
// put the main display first, then the rest by the order of displays
let mut display_order: Vec<(i32, i32)> = Vec::new();
if let Some(d) = displays.displays.get(displays.primary) {
display_order.push((d.x, d.y));
}
for (i, d) in displays.displays.iter().enumerate() {
if i != displays.primary {
display_order.push((d.x, d.y));
}
}
let mut sorted_streams = Vec::new();
let mut sorted_shared_displays = Vec::new();
// Move matching items in order without cloning
for (x, y) in display_order.into_iter() {
for i in 0..streams.len() {
if streams[i].position.0 == x && streams[i].position.1 == y {
sorted_streams.push(streams.remove(i));
// shared_displays.len() must be equal to streams.len()
// But we still check the length to avoid panic
if shared_displays.len() > i {
sorted_shared_displays.push(shared_displays.remove(i));
}
break;
}
}
}
*streams = sorted_streams;
*shared_displays = sorted_shared_displays;
}

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk
pkgver=1.4.3
pkgver=1.4.5
pkgrel=0
epoch=
pkgdesc=""

View File

@@ -34,19 +34,26 @@ def view_shared_abs(url, token, name=None):
filtered_params["pageSize"] = pageSize
abs = []
current = 1
current = 0
while True:
current += 1
filtered_params["current"] = current
response = requests.get(f"{url}/api/ab/shared/profiles", headers=headers, params=filtered_params)
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
data = response_json.get("data", [])
abs.extend(data)
total = response_json.get("total", 0)
current += pageSize
if len(data) < pageSize or current > total:
if len(data) < pageSize or current * pageSize >= total:
break
return abs
@@ -79,19 +86,26 @@ def view_ab_peers(url, token, ab_guid, peer_id=None, alias=None):
filtered_params["pageSize"] = pageSize
peers = []
current = 1
current = 0
while True:
current += 1
filtered_params["current"] = current
response = requests.get(f"{url}/api/ab/peers", headers=headers, params=filtered_params)
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
data = response_json.get("data", [])
peers.extend(data)
total = response_json.get("total", 0)
current += pageSize
if len(data) < pageSize or current > total:
if len(data) < pageSize or current * pageSize >= total:
break
return peers
@@ -103,11 +117,6 @@ def view_ab_tags(url, token, ab_guid):
response = requests.get(f"{url}/api/ab/tags/{ab_guid}", headers=headers)
response_json = check_response(response)
# Handle error responses
if isinstance(response_json, tuple) and response_json[0] == "Failed":
print(f"Error: {response_json[1]} - {response_json[2]}")
return []
# Format color values as hex
if response_json:
for tag in response_json:
@@ -122,14 +131,18 @@ def view_ab_tags(url, token, ab_guid):
def check_response(response):
"""Check API response and return result"""
if response.status_code == 200:
try:
response_json = response.json()
return response_json
except ValueError:
return response.text or "Success"
else:
return "Failed", response.status_code, response.text
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
try:
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
return response_json
except ValueError:
return response.text or "Success"
def add_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None):
@@ -390,19 +403,26 @@ def view_ab_rules(url, token, ab_guid):
}
rules = []
current = 1
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(f"{url}/api/ab/rules", headers=headers, params=params)
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
data = response_json.get("data", [])
rules.extend(data)
total = response_json.get("total", 0)
current += pageSize
if len(data) < pageSize or current > total:
if len(data) < pageSize or current * pageSize >= total:
break
# Convert numeric permissions to string format

View File

@@ -149,14 +149,18 @@ def enhance_audit_data(data, audit_type):
def check_response(response):
"""Check API response and return result"""
if response.status_code == 200:
try:
response_json = response.json()
return response_json
except ValueError:
return response.text or "Success"
else:
return "Failed", response.status_code, response.text
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
try:
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
return response_json
except ValueError:
return response.text or "Success"
def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None,
@@ -216,7 +220,7 @@ def view_audits_common(url, token, endpoint, filters=None, page_size=None, curre
string_params[k] = v
response = requests.get(f"{url}/api/audits/{endpoint}", headers=headers, params=string_params)
response_json = response.json()
response_json = check_response(response)
# Enhance the data with readable formats
data = enhance_audit_data(response_json.get("data", []), endpoint)

274
res/device-groups.py Executable file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
import requests
import argparse
import json
def check_response(response):
"""
Check API response and handle errors.
Two error cases:
1. Status code is not 200 -> exit with error
2. Response contains {"error": "xxx"} -> exit with error
"""
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
# Check for {"error": "xxx"} in response
if response.text and response.text.strip():
try:
json_data = response.json()
if isinstance(json_data, dict) and "error" in json_data:
print(f"Error: {json_data['error']}")
exit(1)
return json_data
except ValueError:
return response.text
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
# ---------- Device Group APIs ----------
def list_groups(url, token, name=None, page_size=50):
headers = headers_with(token)
params = {"pageSize": page_size}
if name:
params["name"] = name
data, current = [], 0
while True:
current += 1
params["current"] = current
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
if r.status_code != 200:
print(f"Error: HTTP {r.status_code} - {r.text}")
exit(1)
res = r.json()
if "error" in res:
print(f"Error: {res['error']}")
exit(1)
rows = res.get("data", [])
data.extend(rows)
total = res.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return data
def get_group_by_name(url, token, name):
groups = list_groups(url, token, name)
for g in groups:
if str(g.get("name")) == name:
return g
return None
def create_group(url, token, name, note=None, accessed_from=None):
headers = headers_with(token)
payload = {"name": name}
if note:
payload["note"] = note
if accessed_from:
payload["allowed_incomings"] = accessed_from
r = requests.post(f"{url}/api/device-groups", headers=headers, json=payload)
return check_response(r)
def update_group(url, token, name, new_name=None, note=None, accessed_from=None):
headers = headers_with(token)
g = get_group_by_name(url, token, name)
if not g:
print(f"Error: Group '{name}' not found")
exit(1)
guid = g.get("guid")
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
if accessed_from is not None:
payload["allowed_incomings"] = accessed_from
r = requests.patch(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
check_response(r)
return "Success"
def delete_groups(url, token, names):
headers = headers_with(token)
if isinstance(names, str):
names = [names]
for n in names:
g = get_group_by_name(url, token, n)
if not g:
print(f"Error: Group '{n}' not found")
exit(1)
guid = g.get("guid")
r = requests.delete(f"{url}/api/device-groups/{guid}", headers=headers)
check_response(r)
return "Success"
# ---------- Device group assign APIs (name -> guid) ----------
def view_devices(url, token, group_name=None, id=None, device_name=None,
user_name=None, device_username=None, page_size=50):
"""View devices in a device group with filters"""
headers = headers_with(token)
# Separate exact match and fuzzy match params
params = {}
fuzzy_params = {
"id": id,
"device_name": device_name,
"user_name": user_name,
"device_username": device_username,
}
# Add device_group_name without wildcard (exact match)
if group_name:
params["device_group_name"] = group_name
# Add wildcard for fuzzy search to other params
for k, v in fuzzy_params.items():
if v is not None:
params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v
params["pageSize"] = page_size
data, current = [], 0
while True:
current += 1
params["current"] = current
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
if r.status_code != 200:
return check_response(r)
res = r.json()
rows = res.get("data", [])
data.extend(rows)
total = res.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return data
def add_devices(url, token, group_name, device_ids):
headers = headers_with(token)
g = get_group_by_name(url, token, group_name)
if not g:
return f"Group '{group_name}' not found"
guid = g.get("guid")
payload = device_ids if isinstance(device_ids, list) else [device_ids]
r = requests.post(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
return check_response(r)
def remove_devices(url, token, group_name, device_ids):
headers = headers_with(token)
g = get_group_by_name(url, token, group_name)
if not g:
return f"Group '{group_name}' not found"
guid = g.get("guid")
payload = device_ids if isinstance(device_ids, list) else [device_ids]
r = requests.delete(f"{url}/api/device-groups/{guid}/devices", headers=headers, json=payload)
return check_response(r)
def parse_rules(s):
if not s:
return None
try:
v = json.loads(s)
if isinstance(v, list):
# expect list of {"type": number, "name": string}
return v
except Exception:
pass
return None
def main():
parser = argparse.ArgumentParser(description="Device Group manager")
parser.add_argument("command", choices=[
"view", "add", "update", "delete",
"view-devices", "add-devices", "remove-devices"
], help=(
"Command to execute. "
"[view/add/update/delete/add-devices/remove-devices: require Device Group Permission] "
"[view-devices: require Device Permission]"
))
parser.add_argument("--url", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--name", help="Device group name (exact match)")
parser.add_argument("--new-name", help="New device group name (for update)")
parser.add_argument("--note", help="Note")
parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)")
parser.add_argument("--ids", help="Comma separated device IDs for add-devices/remove-devices")
# Filters for view-devices command
parser.add_argument("--id", help="Device ID filter (for view-devices)")
parser.add_argument("--device-name", help="Device name filter (for view-devices)")
parser.add_argument("--user-name", help="User name filter (owner of device, for view-devices)")
parser.add_argument("--device-username", help="Device username filter (logged in user on device, for view-devices)")
args = parser.parse_args()
while args.url.endswith("/"): args.url = args.url[:-1]
if args.command == "view":
res = list_groups(args.url, args.token, args.name)
print(json.dumps(res, indent=2))
elif args.command == "add":
if not args.name:
print("Error: --name is required")
exit(1)
print(create_group(
args.url, args.token, args.name, args.note,
parse_rules(args.accessed_from)
))
elif args.command == "update":
if not args.name:
print("Error: --name is required")
exit(1)
print(update_group(
args.url, args.token, args.name, args.new_name, args.note,
parse_rules(args.accessed_from)
))
elif args.command == "delete":
if not args.name:
print("Error: --name is required (supports comma separated)")
exit(1)
names = [x.strip() for x in args.name.split(",") if x.strip()]
print(delete_groups(args.url, args.token, names))
elif args.command == "view-devices":
res = view_devices(
args.url,
args.token,
group_name=args.name,
id=args.id,
device_name=args.device_name,
user_name=args.user_name,
device_username=args.device_username
)
print(json.dumps(res, indent=2))
elif args.command in ("add-devices", "remove-devices"):
if not args.name or not args.ids:
print("Error: --name and --ids are required for add/remove devices")
exit(1)
ids = [x.strip() for x in args.ids.split(",") if x.strip()]
if args.command == "add-devices":
print(add_devices(args.url, args.token, args.name, ids))
else:
print(remove_devices(args.url, args.token, args.name, ids))
if __name__ == "__main__":
main()

View File

@@ -34,12 +34,20 @@ def view(
devices = []
current = 1
current = 0
while True:
current += 1
params["current"] = current
response = requests.get(f"{url}/api/devices", headers=headers, params=params)
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
data = response_json.get("data", [])
@@ -54,22 +62,25 @@ def view(
devices.append(device)
total = response_json.get("total", 0)
current += pageSize
if len(data) < pageSize or current > total:
if len(data) < pageSize or current * pageSize >= total:
break
return devices
def check(response):
if response.status_code == 200:
try:
response_json = response.json()
return response_json
except ValueError:
return response.text or "Success"
else:
return "Failed", response.status_code, response.text
if response.status_code != 200:
print(f"Error: HTTP {response.status_code} - {response.text}")
exit(1)
try:
response_json = response.json()
if "error" in response_json:
print(f"Error: {response_json['error']}")
exit(1)
return response_json
except ValueError:
return response.text or "Success"
def disable(url, token, guid, id):

View File

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

View File

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

View File

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

301
res/strategies.py Executable file
View File

@@ -0,0 +1,301 @@
#!/usr/bin/env python3
import requests
import argparse
import json
def check_response(response):
"""
Check API response and handle errors.
Two error cases:
1. Status code is not 200 -> exit with error
2. Response contains {"error": "xxx"} -> exit with error
"""
if response.status_code != 200:
print(f"Error: HTTP {response.status_code}: {response.text}")
exit(1)
# Check for {"error": "xxx"} in response
if response.text and response.text.strip():
try:
json_data = response.json()
if isinstance(json_data, dict) and "error" in json_data:
print(f"Error: {json_data['error']}")
exit(1)
return json_data
except ValueError:
return response.text
return None
def headers_with(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
# ---------- Strategies APIs ----------
def list_strategies(url, token):
"""List all strategies"""
headers = headers_with(token)
r = requests.get(f"{url}/api/strategies", headers=headers)
return check_response(r)
def get_strategy_by_guid(url, token, guid):
"""Get strategy by GUID"""
headers = headers_with(token)
r = requests.get(f"{url}/api/strategies/{guid}", headers=headers)
return check_response(r)
def get_strategy_by_name(url, token, name):
"""Get strategy by name"""
strategies = list_strategies(url, token)
if not strategies:
return None
for s in strategies:
if str(s.get("name")) == name:
return s
return None
def enable_strategy(url, token, name):
"""Enable a strategy"""
headers = headers_with(token)
strategy = get_strategy_by_name(url, token, name)
if not strategy:
print(f"Error: Strategy '{name}' not found")
exit(1)
guid = strategy.get("guid")
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=True)
check_response(r)
return "Success"
def disable_strategy(url, token, name):
"""Disable a strategy"""
headers = headers_with(token)
strategy = get_strategy_by_name(url, token, name)
if not strategy:
print(f"Error: Strategy '{name}' not found")
exit(1)
guid = strategy.get("guid")
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=False)
check_response(r)
return "Success"
def get_device_guid_by_id(url, token, device_id):
"""Get device GUID by device ID (exact match)"""
headers = headers_with(token)
params = {"id": device_id, "pageSize": 50}
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
res = check_response(r)
if not res:
return None
devices_data = res.get("data", []) if isinstance(res, dict) else res
for d in devices_data:
if d.get("id") == device_id:
return d.get("guid")
return None
def get_user_guid_by_name(url, token, name):
"""Get user GUID by exact name match"""
headers = headers_with(token)
params = {"name": name, "pageSize": 50}
r = requests.get(f"{url}/api/users", headers=headers, params=params)
res = check_response(r)
if not res:
return None
users_data = res.get("data", []) if isinstance(res, dict) else res
for u in users_data:
if u.get("name") == name:
return u.get("guid")
return None
def get_device_group_guid_by_name(url, token, name):
"""Get device group GUID by exact name match"""
headers = headers_with(token)
params = {"pageSize": 50, "name": name}
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
res = check_response(r)
if not res:
return None
groups_data = res.get("data", []) if isinstance(res, dict) else res
for g in groups_data:
if g.get("name") == name:
return g.get("guid")
return None
def assign_strategy(url, token, strategy_name, peers=None, users=None, device_groups=None):
"""
Assign strategy to peers, users, or device groups
Args:
strategy_name: Name of the strategy (or None to unassign)
peers: List of device IDs or GUIDs
users: List of user names or GUIDs
device_groups: List of device group names or GUIDs
"""
headers = headers_with(token)
# Get strategy GUID if strategy_name is provided
strategy_guid = None
if strategy_name:
strategy = get_strategy_by_name(url, token, strategy_name)
if not strategy:
print(f"Error: Strategy '{strategy_name}' not found")
exit(1)
strategy_guid = strategy.get("guid")
# Convert device IDs to GUIDs
peer_guids = []
if peers:
for peer in peers:
# Check if it's already a GUID format
if len(peer) == 36 and peer.count('-') == 4:
peer_guids.append(peer)
else:
# Treat as device ID, look it up
guid = get_device_guid_by_id(url, token, peer)
if not guid:
print(f"Error: Device '{peer}' not found")
exit(1)
peer_guids.append(guid)
# Convert user names to GUIDs
user_guids = []
if users:
for user in users:
# Check if it's already a GUID format
if len(user) == 36 and user.count('-') == 4:
user_guids.append(user)
else:
# Treat as username, look it up
guid = get_user_guid_by_name(url, token, user)
if not guid:
print(f"Error: User '{user}' not found")
exit(1)
user_guids.append(guid)
# Convert device group names to GUIDs
device_group_guids = []
if device_groups:
for dg in device_groups:
# Check if it's already a GUID format
if len(dg) == 36 and dg.count('-') == 4:
device_group_guids.append(dg)
else:
# Treat as device group name, look it up
guid = get_device_group_guid_by_name(url, token, dg)
if not guid:
print(f"Error: Device group '{dg}' not found")
exit(1)
device_group_guids.append(guid)
# Build payload
payload = {}
if strategy_guid:
payload["strategy"] = strategy_guid
payload["peers"] = peer_guids
payload["users"] = user_guids
payload["groups"] = device_group_guids
r = requests.post(f"{url}/api/strategies/assign", headers=headers, json=payload)
check_response(r)
def main():
parser = argparse.ArgumentParser(description="Strategy manager")
parser.add_argument("command", choices=[
"list", "view", "enable", "disable", "assign", "unassign"
])
parser.add_argument("--url", required=True, help="Server URL")
parser.add_argument("--token", required=True, help="API token")
parser.add_argument("--name", help="Strategy name (for view/enable/disable/assign commands)")
parser.add_argument("--guid", help="Strategy GUID (for view command, alternative to --name)")
# For assign/unassign commands
parser.add_argument("--peers", help="Comma separated device IDs or GUIDs (requires Device Permission:r)")
parser.add_argument("--users", help="Comma separated user names or GUIDs (requires User Permission:r)")
parser.add_argument("--device-groups", help="Comma separated device group names or GUIDs (requires Device Group Permission:r)")
args = parser.parse_args()
while args.url.endswith("/"): args.url = args.url[:-1]
if args.command == "list":
res = list_strategies(args.url, args.token)
print(json.dumps(res, indent=2))
elif args.command == "view":
if args.guid:
res = get_strategy_by_guid(args.url, args.token, args.guid)
print(json.dumps(res, indent=2))
elif args.name:
strategy = get_strategy_by_name(args.url, args.token, args.name)
if not strategy:
print(f"Error: Strategy '{args.name}' not found")
exit(1)
# Get full details by GUID
guid = strategy.get("guid")
res = get_strategy_by_guid(args.url, args.token, guid)
print(json.dumps(res, indent=2))
else:
print("Error: --name or --guid is required for view command")
exit(1)
elif args.command == "enable":
if not args.name:
print("Error: --name is required")
exit(1)
print(enable_strategy(args.url, args.token, args.name))
elif args.command == "disable":
if not args.name:
print("Error: --name is required")
exit(1)
print(disable_strategy(args.url, args.token, args.name))
elif args.command == "assign":
if not args.name:
print("Error: --name is required")
exit(1)
if not args.peers and not args.users and not args.device_groups:
print("Error: at least one of --peers, --users, or --device-groups is required")
exit(1)
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
assign_strategy(args.url, args.token, args.name, peers=peers, users=users, device_groups=device_groups)
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
print(f"Success: Assigned strategy '{args.name}' to {count} target(s)")
elif args.command == "unassign":
if not args.peers and not args.users and not args.device_groups:
print("Error: at least one of --peers, --users, or --device-groups is required")
exit(1)
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
assign_strategy(args.url, args.token, None, peers=peers, users=users, device_groups=device_groups)
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
print(f"Success: Unassigned strategy from {count} target(s)")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More