Compare commits

...

311 Commits

Author SHA1 Message Date
eason
ee8cc0c06b fix(linux): prevent X11 BadWindow crash in get_focused_display (#14561)
* fix(linux): prevent X11 BadWindow crash in get_focused_display

When the active window is destroyed between xdo_get_active_window and
xdo_get_window_location/xdo_get_window_size calls, the default X11
error handler terminates the process with a BadWindow error. This
causes the rustdesk --server process to crash and the remote session
to disconnect and reconnect every time the user closes a window.

Install a custom X error handler around the xdo calls that catches
BadWindow errors and returns gracefully instead of crashing.

Fixes: https://github.com/rustdesk/rustdesk/issues/9003

Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
Signed-off-by: easonysliu <easonysliu@tencent.com>

* fix(linux): prevent BadWindow crash in focus display lookup

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

---------

Signed-off-by: easonysliu <easonysliu@tencent.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: easonysliu <easonysliu@tencent.com>
Co-authored-by: Claude (claude-opus-4-6) <noreply@anthropic.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-04-28 11:04:29 +08:00
s1korrrr
99b565ef40 fix(iOS): preserve local pasteboard sync from Windows hosts (#14659)
* fix(ios): accept windows clipboard updates locally

Signed-off-by: Rafal <mrsikorarafal@gmail.com>

* docs: document clipboard text helpers

* fix(iOS): sync clipboard, debug

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

---------

Signed-off-by: Rafal <mrsikorarafal@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-04-28 10:55:28 +08:00
fufesou
1e6a3dc644 fix(android): waiting for image, one cause (#14919)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-27 22:37:22 +08:00
s1korrrr
5b7ad339b8 fix(iPad): keep touch gestures with external mouse (#14652)
* fix(ipad): keep touch gestures with external mouse

Signed-off-by: Rafal <mrsikorarafal@gmail.com>

* fix(mobile): touch gesture on physical mouse connected

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

* fix(ipad): revert 9ee100b53e

keep touch gestures with external mouse

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

* fix(mobile): align view camera page with remote page

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

---------

Signed-off-by: Rafal <mrsikorarafal@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-04-27 19:44:35 +08:00
Sergiusz Michalik
7308c448f1 fix(client): serialize X11 keyboard grab and debounce focus feedback (#14836)
* fix(client): serialize X11 keyboard grab and debounce focus feedback

When two RustDesk sessions run fullscreen on separate monitors on
Linux/X11, keyboard input gets stuck on the wrong session or stops
working entirely. This happens because each Flutter isolate calls
change_grab_status concurrently, racing on KEYBOARD_HOOKED and the
rdev grab channel.

Additionally, XGrabKeyboard causes a focus-change feedback loop:
grab shifts focus away from the Flutter window, triggering PointerExit,
which releases the grab, restoring focus, triggering PointerEnter,
which re-grabs -- cycling at ~10 Hz and blocking keyboard input.

Fix by:
- Serializing grab transitions with a mutex and tracking the owning
  session (by lc.session_id), so a stale Wait from session A cannot
  clobber session B's freshly acquired grab.
- Debouncing Wait events (300 ms) from the same session that just
  acquired the grab, breaking the X11 focus feedback loop.
- Refreshing the debounce timer on idempotent Run calls (enterView
  while already owner), keeping the grab stable during normal use.

Signed-off-by: Sergiusz Michalik <github@latens.me>

* fix(client): add deferred release and dedup for debounced Wait

When a Wait is debounced (within 300ms of grab acquisition), schedule
a deferred release thread that re-checks after the debounce window.
If no new Run refreshed the grab, the deferred thread releases it,
ensuring a genuine leave within the debounce window is not lost.

Add a deferred_pending flag to GrabOwnerState to prevent spawning
redundant threads during the X11 focus feedback loop.

Signed-off-by: Sergiusz Michalik <github@latens.me>

* fix(client): use window-scoped ID and fix deferred-release re-arming

Address PR review feedback:
- Use per-window UUID instead of connection-scoped lc.session_id so two
  windows viewing the same peer get distinct grab owners
- Reset deferred_pending on both idempotent Run refresh and owner
  handoff, so a subsequent Wait can always spawn a fresh timer
- Replace manual Default impl with derive

* fix(client): recover from poisoned mutex instead of panicking

* docs: clarify cross-platform rationale for GrabOwnerState

* fix(client): only clear deferred_pending when timer snapshot matches

* fix(client): use full u128 window ID, downgrade grab logs to debug

- Widen GrabOwnerState.owner to u128 to avoid theoretical collision
  from truncating a 128-bit UUID to 64 bits
- Downgrade all grab transition log::info! to log::debug! to reduce
  log noise during routine window switches
- Clear deferred_pending on post-debounce release path to maintain
  the "deferred_pending => timer in flight" invariant

* fix(client): gate GRAB_DEBOUNCE_MS with cfg(target_os = "linux")

* fix(grab): release grabbed keys without clobbering new owner state

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

* fix(keyboard): Simple refactor

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

---------

Signed-off-by: Sergiusz Michalik <github@latens.me>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-04-26 22:46:41 +08:00
Amirhosein Akhlaghpoor
c8ba99d1a1 flutter: shift after one shot IME capitalization (#14695)
* flutter: shift after one shot IME capitalization

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

* flutter: clarify stale mobile shift handling

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

* fix(android): gboard shift stuck

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

* fix(android): gboard shift stuck, remove unused param

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

* fix(android): gboard shift stuck, release shift before sending events

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

* chore(flutter): document stale mobile shift release flow

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

---------

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-04-26 22:44:26 +08:00
Azhar
5ea6714db8 Fix: replace unwrap() with proper error handling in CLI password prompt (#14910)
Signed-off-by: bunnysayzz <stfuazzo@gmail.com>
2026-04-26 21:28:05 +08:00
fufesou
3a1622e8b5 refact(AGENTS.md): code rules, tokio (#14911)
* refact(AGENTS.md): code rules, tokio

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

* Update AGENTS.md

* Update AGENTS.md

* Update AGENTS.md

* Update AGENTS.md

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-04-26 21:25:31 +08:00
Sergiusz Michalik
38f1300717 fix(linux): enable mouse side buttons in remote sessions (#14848)
* fix(linux): enable mouse side buttons in remote sessions

Flutter's Linux embedder never delivers X11 button 8/9 (back/forward)
events to Dart, so mouse side buttons were silently dropped in remote
sessions.

Intercept these buttons at the GDK level via button-press/release-event
handlers on all windows (main + sub-windows) and forward them through
a dedicated platform channel to the active InputModel session.

Also add a defensive XSetPointerMapping call during enigo init to
extend the X11 core pointer button map to 9 buttons on servers where
it is smaller (e.g. minimal X server configurations).

* fix: address review feedback for side button support

- Use XOpenDisplay/XCloseDisplay instead of reading Display* from
  xdo_t's private struct layout at offset 0 (fragile ABI assumption)
- Track side button down ownership per button via a Map instead of a
  single slot, preventing cross-button mismatch on overlapping presses

* fix: gate side buttons on view-only and fix teardown

- Skip side button events in view-only sessions (consistent with
  other mouse entry points)
- Release held side buttons on session close to avoid stuck buttons
  on the remote
- Drop unpaired 'up' events instead of falling back to the active
  model, which could send to the wrong session

* docs: add clarifying comments from review feedback

- Note global scope of XSetPointerMapping and that it runs once
  via lazy_static singleton
- Clarify sub-window callback is safe on X11-only builds
- Document per-isolate design of initSideButtonChannel

* fix: replace broken XSetPointerMapping with diagnostic check

XSetPointerMapping requires the length to match XGetPointerMapping's
return value - it cannot extend the button count. The previous code
would trigger a BadValue X error on servers with fewer than 9 buttons.

Replace with a diagnostic-only check that logs whether the core
pointer has enough buttons for side button simulation. RustDesk's
uinput "Mouse passthrough" device already provides the needed buttons
in practice.

Also add .catchError to fire-and-forget side button releases during
session teardown to prevent unhandled async errors.

* fix: ensure side button releases bypass permission checks

If permissions change between button down and up (e.g. keyboardPerm
revoked, view-only toggled), sendMouse's early return would suppress
the release, leaving a stuck button on the remote.

Add _sendMouseUnchecked that bypasses permission checks, used for:
- Side button 'up' events (matching a recorded 'down')
- Forced releases during session teardown

Gate all permission checks (isViewOnly, keyboardPerm, isViewCamera)
at the 'down' entry point before recording in _sideButtonDownModels.

* fix: add NULL guards and avoid blocking platform channel handler

- Add NULL checks for FL_VIEW cast and channel creation in
  on_subwindow_created (review feedback from fufesou)
- Use fire-and-forget (unawaited) for _sendMouseUnchecked calls
  inside the platform channel handler to avoid blocking platform
  messages when sessionSendMouse is slow (review feedback from Copilot)

* fix: remove circular import and skip X11 check on Wayland

- Move initSideButtonChannel() call from initEnv() in main.dart to
  the InputModel constructor, removing the circular import between
  main.dart and input_model.dart
- Skip check_x11_button_map() when DISPLAY is not set to avoid
  noisy warnings on pure Wayland environments
2026-04-25 12:46:05 +08:00
Nawer
03e351ac61 feat(i18n): Complete and fix french translations (#14890) 2026-04-24 18:38:34 +08:00
fufesou
6cb323725b fix(sicter): control side, privacy mode (#14880)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-24 14:35:58 +08:00
Aliaksandr Kliujeŭ
5d0533f0d4 Update Balarusian strings (#14842)
* Update Balarusian strings

* BE: fix typos

* BE: fix ў-related typos
2026-04-23 23:52:43 +08:00
Re*Index. (ot_inc)
e0c5e1483e Update Japanese translate (#14838)
* Update ja.rs

* Update ja.rs

* Fix typo
2026-04-23 23:52:21 +08:00
Leo Louis
47e4c65d8e Update print statement from 'Hello' to 'Goodbye' (#14754) 2026-04-22 18:06:37 +08:00
Leo Louis
9bc1ce52af Add Malayalam language support (#14753)
* Add Malayalam language support

* Fix syntax error in language list for Malayalam

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-04-22 18:06:10 +08:00
Leo Louis
348d1b46e1 Add Hindi language support with translations (#14746)
* Add Hindi language support with translations

* Update print statement from 'Hello' to 'Goodbye'
2026-04-22 18:04:37 +08:00
Leo Louis
1a41b3ac11 Add Hindi language module and translation support (#14745)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-04-22 18:04:09 +08:00
rustdesk
b239535009 refactor per code review 2026-04-22 01:41:13 +08:00
RustDesk
5fd20f808c fix safari-oidc https://github.com/rustdesk/rustdesk/issues/14861 (#14867) 2026-04-22 01:29:15 +08:00
rustdesk
803ac8cc4e save cargo build size 2026-04-21 17:34:05 +08:00
John Eismeier
4a50bc6fc2 Propose fix some typos (#14857)
Signed-off-by: John E <jeis4wpi@outlook.com>
2026-04-21 16:27:39 +08:00
fufesou
e8a1b7fe21 fix: build (#14846)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-20 10:05:32 +08:00
21pages
ac124c0680 flutter: improve address book pull error handling (#14813)
* flutter: improve address book pull error handling

Summary:
  - Show error messages when fetching the address book list fails.
  - After the initial fetch, switching back to the AB tab no longer re-fetches it, even if an error occurred or the error banner was dismissed.

  Tested:
  - Self-hosted server:
    - normal
    - 403 responses
    - legacy address book mode
  - Public server
  - Verified that switching tabs no longer re-fetches AB after the initial fetch, regardless of whether an error occurred or the error banner was cleared.

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

* use resp.statusCode in address book json decoding

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

* flutter: clear address book list errors on reset

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

* flutter: clear address book pull errors consistently

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-04-18 11:19:32 +08:00
Luca-rickrolled-himself
91aff3ffd1 Complete and correct Romanian (ro) translations (#14837)
* Complete and correct Romanian (ro) translations

- Fill in all previously empty translation strings
- Fix plural form: "fișier" → "fișiere" (files)
- Fix "Receive" → "Primește" (was incorrectly using "Acceptă")
- Fix "Too frequent" → "Prea frecvent" (removed erroneous extra word)
- Fix "Note" → "Notă" (was translated as verb instead of noun)
- Fix "Use both passwords" → "Folosește ambele parole" ("programe" typo)
- Fix "Automatically record incoming sessions" → "sesiunile primite" (not "viitoare")
- Fix typo "neautoriztă" → "neautorizată" (Connection not allowed)
- Fix typo "dispozivul" → "dispozitivul" (Restart remote device)
- Fix leading whitespace in "Username" translation
- Fix "FPS" → keep as "FPS" (was incorrectly translated as "CPS")
- Fix "Forget Password" → "Parolă uitată" (command form was grammatically wrong)

* Fix typo in Romanian translation for accessibility tip

* unify informal register and fix subjunctive typo
2026-04-18 10:55:18 +08:00
John Fowler
642c281ad0 Update hu.rs (#14816)
New string translation and fixes.
2026-04-17 12:44:24 +08:00
fufesou
1e9c4d04f1 fix(mobile): deeplink, disable by default (#14824)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-16 23:21:14 +08:00
21pages
9f817714fe fix(client): stop retrying on restricted mobile access errors (#14797)
Treat "Access to mobile devices is restricted in your country"
  as a non-retriable connection error so the error dialog does not
  trigger reconnect attempts.

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-04-15 21:40:03 +08:00
pallab-js
091f2c6135 impl(cm): implement change_theme and change_language callbacks (#14782)
* docs: fix typos in documentation and code comments

- Fix 'seperated' -> 'separated' in remote_input.dart
- Fix 'seperators' -> 'separators' in fuse/cs.rs
- Update outdated 'OSX' -> 'macOS' in virtual display README

Signed-off-by: pallab-js <sonowalpallabjyoti@gmail.com>

* impl(cm): implement change_theme and change_language callbacks

These callbacks were previously empty TODO stubs.
Now they properly invoke the Sciter UI handlers to notify
the UI when theme or language changes occur.

Signed-off-by: pallab-js <sonowalpallabjyoti@gmail.com>

---------

Signed-off-by: pallab-js <sonowalpallabjyoti@gmail.com>
2026-04-15 17:35:51 +08:00
rustdesk
91de51290d add microsoft oidc logo 2026-04-15 14:39:46 +08:00
rustdesk
68fa0466c8 improved oidc login error 2026-04-15 14:36:03 +08:00
Leo Louis
28e303576c Add support for Gujarati language in lang.rs (#14751) 2026-04-14 14:21:27 +08:00
Leo Louis
2d41b3e80d Add Gujarati language support with translations (#14752) 2026-04-14 14:21:10 +08:00
Andrzej Rudnik
ffd2d26c1a Update pl.rs (#14775) 2026-04-14 14:20:35 +08:00
Leo Louis
a8dc6fc632 Fix capture method return type in Recorder trait (#14748) 2026-04-13 13:04:43 +08:00
Leo Louis
771cb4ebd7 Update capture function return type for PixelProvider (#14747) 2026-04-13 13:03:35 +08:00
fufesou
2f694c0eb2 fix: file transfer, path traversal (#14678)
* fix: file transfer, path traversal

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

* fix(fs): remove stale files

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

* fix(fs): update_folder_files() after set_files()

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

* fix(fs): reduce .clone()

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

* fix(fs): undo checking "done message for unkown id"

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

* fix(fs): refactor

1. Hide `files` in `new_write()`.
2. Use `set_files()` to validate `files` before writing.

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

* fix(fs): comments

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

* fix(fs): Remove redundant checks

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

* fix(fs): update hbb_common

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-10 18:00:11 +08:00
21pages
8dea347a21 add brute-force protection for one-time password (#14682)
* add brute-force protection for temporary password

  Rotate the temporary password after repeated failed login attempts
  within one minute, and reset the failure window after successful
  authentication.

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

* replace LazyLock with lazy_static

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

* read temporary password after locking failure state

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

* server: rotate temporary passwords after 10 consecutive failures

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

* server: clarify temporary password failure counter comment

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-04-09 17:14:21 +08:00
rustdesk
0cf3e8ed40 improve agent md 2026-04-09 15:12:57 +08:00
21pages
9d3bc7d9e6 fix switch sides for macOS peers (#14661)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-04-07 23:39:24 +08:00
🌐 Qusai ALBahri 🌱
e0427bdc77 Translate UI strings to Arabic in ar.rs (#14694) 2026-04-06 18:27:14 +08:00
fufesou
9cf1338dc4 fix(win): exe icon path (#14686)
* fix(win): exe icon path

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

* fix(win): Simple refactor

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-04 22:54:13 +08:00
RustDesk
4e30ee8d1c tcp proxy (#14633)
* tcp proxy

* fix per review

* fix per review

* Suppress secure_tcp info logs for TCP proxy requests

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

* copilot review: redact tcp proxy logs, dedupe headers, and avoid body clone

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

* format common.rs

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

* copilot review: test function name

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

* copilot review: format IPv6 tcp proxy log targets correctly

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

* copilot review: normalize HTTP method before direct request dispatch

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

* review: extract fallback helper, fix Content-Type override, add overall timeout

- Extract duplicated TCP proxy fallback logic into generic
  `with_tcp_proxy_fallback` helper used by both `post_request` and
  `http_request_sync`, eliminating code drift risk
- Allow caller-supplied Content-Type to override the default in
  `parse_simple_header` instead of silently dropping it
- Take body by reference in `post_request_http` to avoid eager clone
  when no fallback is needed
- Wrap entire `tcp_proxy_request` flow (connect + handshake + send +
  receive) in an overall timeout to prevent indefinite stalls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* review: make is_public case-insensitive and cover mixed-case rustdesk URLs

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

* oidc: route auth requests through shared HTTP/tcp-proxy path while keeping TLS warmup

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

* refactor: replace unused TryFrom<Response> with HbbHttpResponse::parse method

  Remove TryFrom<Response> impl that was never called and replace the
  private parse_hbb_http_response helper in account.rs with a public
  parse() method on HbbHttpResponse, eliminating code duplication.

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 23:13:05 +08:00
Alex Rijckaert
cca6a5fe12 Update Dutch translations (#14654) 2026-04-01 18:10:39 +08:00
VenusGirl❤
9e4b7fca4d Update Korean (#14644) 2026-03-31 21:34:35 +08:00
XLion
d135c58ead Update tw.rs (#14643) 2026-03-31 21:26:00 +08:00
Mr-Update
de194417d4 Update de.rs (#14640) 2026-03-31 21:25:05 +08:00
solokot
d01ce3173f Update ru.rs (#14636) 2026-03-30 22:37:35 +08:00
bilimiyorum
010a54d1c9 Update tr.rs (#14628)
New string entries
2026-03-29 23:02:53 +08:00
bovirus
f557fc94fa Italian language update (#14626) 2026-03-28 13:02:09 +08:00
21pages
f02cd9c0f6 Fix Windows session-based logon and lock-screen detection (#14620)
* Fix Windows session-based logon and lock-screen detection

  - scope LogonUI and locked-state checks to the current Windows session
  - allow permanent password fallback for logon and lock-screen access

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

* Log permanent-password fallback on logon screen

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-27 13:22:16 +08:00
fufesou
170516572e refact(password): Store permanent password as hashed verifier (#14619)
* refact(password): Store permanent password as hashed verifier

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

* fix(password): remove unused code

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

* fix(password): mobile, password dialog, width 500

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-26 14:49:54 +08:00
fufesou
285e29d2dc fix(shell): check kv in update_install_option (#14564)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-26 12:08:29 +08:00
rustdesk
aab34b2338 remove winget 2026-03-25 16:36:35 +08:00
rustdesk
ad1e5330e9 update hbb_common 2026-03-24 20:39:44 +08:00
bovirus
ca4647ddd6 Italian language update (#14598) 2026-03-23 13:48:34 +08:00
Mr-Update
7004acae46 Update de.rs (#14572) 2026-03-21 16:18:56 +08:00
solokot
899dd46f5b Update ru.rs (#14570) 2026-03-21 16:18:39 +08:00
linsui
dba5fea66f Fix F-Droid 1.4.6 build (#13601)
* build_fdroid.sh: avoid using github api

* build_fdroid.sh: Find correct LLVM path for LLVM > 15.x

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>

* build_fdroid.sh: formatting / spelling

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>

---------

Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>
Co-authored-by: Vasyl Gello <vasek.gello@gmail.com>
2026-03-20 13:45:35 +08:00
21pages
c457b0e7d3 add option to hide stop-service when service is running (#14563)
* add option to hide stop-service when service is running

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

* update hbb_common to upstream

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-19 20:04:10 +08:00
Lynilia
c0da4a6645 Update fr.rs (#14567) 2026-03-18 22:53:30 +08:00
Qusai Ismael
9d8df6a226 Fix(wayland): improve error message when xdg-desktop-portal is unavailable #12897 (#14543)
* Fix: Wayland requires higher version of linux distro. Please try X11 desktop or change your OS. #12897

* refactor(wayland): optimize translation keys for binary size and improve dbus matching
2026-03-17 13:37:20 +08:00
Copilot
02da7132e7 Fix: note dialog not shown when closing session from reconnecting screen (#14528)
* Initial plan

* Fix: show ask-for-note dialog when user clicks OK on reconnecting screen (#14527)

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* fix: don't clear audit_guid during reconnect, clear it after connection established

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-03-16 18:27:39 +08:00
rustdesk
e3b6e4eaf0 update tray icon crate to fix icon conflict 2026-03-14 15:54:54 +08:00
RustDesk
0388d00ad3 Revert "Update tray-icon crate to fix Linux tray icon collision (#14530)" (#14538)
This reverts commit 1e2d2c5146.
2026-03-14 14:49:55 +08:00
Eric Blanquer
1e2d2c5146 Update tray-icon crate to fix Linux tray icon collision (#14530)
Bump tray-icon from 0.14.3 to 0.21.3 which includes the fix
from tauri-apps/tray-icon#290 that derives the icon id from the
process id, preventing icon collisions between apps using the
same crate (e.g. Synergy, R-Quick-Share).

Refs: https://github.com/rustdesk/rustdesk/discussions/14165
2026-03-14 14:48:20 +08:00
rustdesk
96797742f2 fix https://github.com/rustdesk/rustdesk/issues/14520 2026-03-13 10:42:13 +08:00
Vasyl Gello
682e347be0 Bump Android NDK to r28c (#13685)
Signed-off-by: Vasyl Gello <vasek.gello@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-03-12 16:52:33 +08:00
fufesou
b3f43f55c1 fix(mobile): restore canvas offset after hidding the soft keyboard (#14506)
* fix(mobile): restore canvas offset after hidding the soft keyboard

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

* fix(mobile): ingore mobileFocusCanvasCursor in didChangeMetrics

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

* fix(mobile): remove unused code

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

* refact(mobile): simple refactor

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

* fix(mobile): restore canvas, cancel focus timer

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-11 18:28:37 +08:00
21pages
016a0b1141 fix strategy cannot apply over default advanced options (#14502)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-10 13:24:13 +08:00
John Fowler
fd7bcf54bd Hungarian language file update (#14497)
* Update Hungarian translations in hu.rs

Translation of new strings and some fixes.
John Fowler.

* Escape quotes in Hungarian language strings

Replacing Hungarian quotation marks

* Update Hungarian translations for various terms

Upload a new translation (hu.rs) file.

* Hungarian language file correction

New character strings translation, error correction.

* Hungarian language file update

New string translations.
2026-03-09 21:28:37 +08:00
layla
db3f5fe816 Fix typo: Rustdesk to RustDesk in Russian README (#14468) 2026-03-08 19:18:59 +08:00
fufesou
0d3016fcd8 fix(flutter): reduce accidental horizontal trackpad scrolling during vertical pan (#14460)
* fix(flutter): reduce accidental horizontal trackpad scrolling during vertical pan

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

* refact: comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-05 23:10:39 +08:00
21pages
1abc897c45 fix avatar fallback (#14458)
* fix avatar fallback

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

* fix(ui): improve avatar fallback handling and layout consistency

  - Always show spacing in account section regardless of avatar presence
  - Handle null return from buildAvatarWidget with proper fallback
  - Adjust mobile settings avatar size to 28

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-05 12:30:40 +08:00
RustDesk
ab64a32f30 avatar (#14440)
* avatar

* refactor avatar display: unify rendering and resolve at use time

  - Extract buildAvatarWidget() in common.dart to share avatar rendering
    logic across desktop settings, desktop CM and mobile CM
  - Add resolve_avatar_url() in Rust, exposed via FFI (SyncReturn),
    to resolve relative avatar paths (e.g. "/avatar/xxx") to absolute URLs
  - Store avatar as-is in local config, only resolve when displaying
    (settings page) or sending (LoginRequest)
  - Resolve avatar in LoginRequest before sending to remote peer
  - Add error handling for network image load failures
  - Guard against empty client.name[0] crash
  - Show avatar in mobile settings page account tile

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

* web: implement mainResolveAvatarUrl via js getByName

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

* increase ipc Data enum size limit to 120 bytes

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-03-04 21:43:19 +08:00
RustDesk
52b66e71d1 Move port mapping afterwards (#14448)
* move port mapping after auth in port forwarding

* fix(port-forward): try connect after 2fa

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

* fix(security): gate port-forward connect on full auth and clarify login flow semantics

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

* refact(port-forward): comments and logs

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-03-04 15:48:42 +08:00
fufesou
41ab5bbdd8 fix(update): macos, test before update (#14446)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-03 10:47:32 +08:00
fufesou
732b250815 fix(keyboard): legacy mode (#14435)
* fix(keyboard): legacy mode

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

* Simple refactor

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

* fix(keyboard): legacy mode, chr to seq

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

* fix(keyboard): legacy mode, early return if (!hotkey)&down

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

* fix(keyboard): legacy mode, pair down/up

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-02 19:07:09 +08:00
rustdesk
157dbdc543 fix avatar in hbb_common 2026-03-02 12:14:26 +08:00
rustdesk
6ba23683d5 avatar in libs/hbb_comon 2026-03-02 12:06:20 +08:00
fufesou
80a5865db3 macOS update: restore LaunchAgent in GUI session and isolate temp update dir by euid (#14434)
* fix(update): macos, load agent

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

* fix(update): macos, isolate temp update dir by euid

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

* refact(update): macos script

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-01 20:06:04 +08:00
MichaIng
9cb6f38aea packaging: deb: remove obsolete Python version check (#14429)
It was used to conditionally install a Python module in the past. But that is not the case anymore since https://github.com/rustdesk/rustdesk/commit/37dbfcc. Now the check is obsolete.

Due to `set -e`, the check leads to a package configuration failure if Python is not installed, which however otherwise is not needed for RustDesk.

The commit includes an indentation fix and trailing space removal.

Signed-off-by: MichaIng <micha@dietpi.com>
2026-03-01 18:05:19 +08:00
fufesou
cd7e3e4505 fix(update): macos, input password (#14430)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-01 15:19:07 +08:00
fufesou
1833cb0655 fix(update): revert check (#14424)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-28 18:17:26 +08:00
fufesou
e4208aa9cf fix(update): revert check (#14423)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-28 16:33:54 +08:00
Amirhosein Akhlaghpoor
bb3501a4f9 ui: scale wheel lines on Windows/Linux to Mac (#14395)
* input: accelerate wheel bursts on Windows->Mac

- boost fast wheel bursts without affecting single-step scrolls\n- use dominant-axis smooth detection and velocity gate\n- reset wheel timestamp on enter/leave\n- enforce single-axis scrolling\n- extract/tune Sciter wheel accel thresholds

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

* input: clarify wheel burst tuning

- add comments on acceleration rules and units\n- apply burst accel on Windows/Linux to macOS\n- reset wheel timing on enter/leave

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

* input: align wheel burst velocity thresholds

- match Flutter velocity gate with Sciter

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

* input: restore flutter wheel velocity threshold

- keep burst threshold at 0.002 delta/us

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>

---------

Signed-off-by: Amirhossein Akhlaghpour <m9.akhlaghpoor@gmail.com>
2026-02-28 10:56:25 +08:00
fufesou
4abdb2e08b feat: windows, custom client, update (#13687)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-27 21:50:20 +08:00
rustdesk
d49ae493b2 bump to 1.4.6 2026-02-27 20:53:40 +08:00
VenusGirl❤
394079833e Update ko.rs (#14418) 2026-02-27 20:14:14 +08:00
Alex Rijckaert
12d6789c2e Update translation (#14413) 2026-02-27 12:09:47 +08:00
memory_clear
34803f8e9b Update labels for keep awake during sessions (#14391) 2026-02-26 18:28:19 +08:00
solokot
fd43184406 Update ru.rs (#14386) 2026-02-26 18:28:09 +08:00
Mr-Update
3cc3315081 Update de.rs (#14385) 2026-02-26 18:27:58 +08:00
RustDesk
6aee70fa18 fix https://github.com/rustdesk/rustdesk/issues/609#issuecomment-3931613118 (#14364) 2026-02-25 17:09:51 +08:00
rustdesk
82a9fd1540 change port forward listen to localhost 2026-02-24 21:57:55 +08:00
rustdesk
dc760d6ca8 remove .claude 2026-02-24 21:52:26 +08:00
Amirhosein Akhlaghpoor
eb239501bc Fix logon-screen password with click approval (#14335) 2026-02-24 21:14:18 +08:00
fufesou
0016033937 feat(terminal): add reconnection buffer support for persistent sessions (#14377)
* feat(terminal): add reconnection buffer support for persistent sessions

Fix two related issues:
1. Reconnecting to persistent sessions shows blank screen - server now
   automatically sends historical buffer on reconnection via SessionState
   machine with pending_buffer, eliminating the need for client-initiated
   buffer requests.
2. Terminal output before view ready causes NaN errors - buffer output
   chunks on client side until terminal view has valid dimensions, then
   flush in order on first valid resize.

Rust side:
- Introduce SessionState enum (Closed/Active) replacing bool is_opened
- Auto-attach pending buffer on reconnection in handle_open()
- Always drain output channel in read_outputs() to prevent overflow
- Increase channel buffer from 100 to 500
- Optimize get_recent() to collect whole chunks (avoids ANSI truncation)
- Extract create_terminal_data_response() helper (DRY)
- Add reconnected flag to TerminalOpened protobuf message

Flutter side:
- Buffer output chunks until terminal view has valid dimensions
- Flush buffered output on first valid resize via _markViewReady()
- Clear terminal on reconnection to avoid duplicate output from buffer replay
- Fix max_bytes type (u32) to match protobuf definition
- Pass reconnected field through FlutterHandler event

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

* fix(terminal): add two-phase SIGWINCH for TUI app redraw and session remap on reconnection

Fix TUI apps (top, htop) not redrawing after reconnection. A single
resize-then-restore is too fast for ncurses to detect a size change,
so split across two read_outputs() polling cycles (~30ms apart) to
force a full redraw.

Also fix reconnection failure when client terminal_id doesn't match
any surviving server-side session ID by remapping the lowest surviving
session to the requested ID.

Rust side:
- Add two-phase SIGWINCH state machine (SigwinchPhase: TempResize →
  Restore → Idle) with retry logic (max 3 attempts per phase)
- Add do_sigwinch_resize() for cross-platform PTY resize (direct PTY
  and Windows helper mode)
- Add session remap logic for non-contiguous terminal_id reconnection
- Extract try_send_output() helper with rate-limited drop logging (DRY)
- Add 3-byte limit to UTF-8 continuation byte skipping in get_recent()
  to prevent runaway on non-UTF-8 binary data
- Remove reconnected flag from flutter.rs (unused on client side)

Flutter side:
- Add reconnection screen clear and deferred flush logic
- Filter self from persistent_sessions restore list
- Add comments for web-related changes

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-24 21:12:06 +08:00
bilimiyorum
50c62d5eac Update tr.rs (#14376)
New string entry
2026-02-24 16:30:32 +08:00
Lynilia
91ac48912e Update fr.rs (#14383) 2026-02-24 16:30:12 +08:00
John Fowler
272a6604cd Hungarian language file correction (#14382)
* Update Hungarian translations in hu.rs

Translation of new strings and some fixes.
John Fowler.

* Escape quotes in Hungarian language strings

Replacing Hungarian quotation marks

* Update Hungarian translations for various terms

Upload a new translation (hu.rs) file.

* Hungarian language file correction

New character strings translation, error correction.
2026-02-24 16:29:54 +08:00
westor
8a889d3ebb Update el.rs translation (#14378)
- Added missing language strings.
- Fixed some previously typo translations.
- Updated some translation strings.
2026-02-24 16:29:43 +08:00
bovirus
17a3f2ae52 Italian language update (#14375) 2026-02-23 16:37:53 +08:00
RustDesk
6c3515588f - UI display: display_name first (#14358)
* - UI display: display_name first
  - Fallback: name
  - Technical identity: still name

  ### What changed

  - Added account display helpers and display_name state in user model:
      - flutter/lib/models/user_model.dart:16
  - Account/logout label now uses display_name (@name) when both exist:
      - flutter/lib/mobile/pages/settings_page.dart:689
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2016
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2135
  - Desktop Account info now shows both when applicable:
      - Display Name: ...
      - Username: ...
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2039
  - Previously done group-list behavior remains:
      - group user list displays display_name with name fallback
      - flutter/lib/common/widgets/my_group.dart:187
  - Persistence path for display_name remains enabled (including group cache/submodule field):
      - libs/hbb_common/src/config.rs:2347
  - src/client.rs:2630
  - LoginRequest.my_name now resolves as:
      1. OPTION_DISPLAY_NAME (manual override)
      2. user_info.display_name
      3. user_info.name
      4. OS username fallback

* 1. GUID key (...Uninstall\{GUID}) is MSI-native metadata generated by Windows Installer.
  2. Non-GUID key (...Uninstall\RustDesk) is explicitly written by RustDesk’s MSI compatibility component in res/msi/Package/Components/Regs.wxs:44, populated by preprocess.py --arp from .github/workflows/
     flutter-build.yml:262.

  So they were not using the same EstimatedSize logic:

  - MSI GUID key: MSI-calculated size (KB).
  - RustDesk key: custom script value from res/msi/preprocess.py:339 (previously bytes, now fixed to KB).

  That mismatch is exactly why you saw different sizes.

* improve display name handling

  - Append (@username) when multiple users share the same display name
  - Trim whitespace from display_name before comparison and display
  - Add missing translate() for Logout button on desktop

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

* group peer filter match both user's display name and user's name

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

* case-insensitive search in group peer filter

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-02-22 14:59:25 +08:00
fufesou
4d2d2118a2 Fix/terminal tab close persistent (#14359)
* fix(terminal): ensure tab close is resilient to session cleanup failures

- Wrap _closeTerminalSessionIfNeeded in isolated try/catch so that
  tabController.closeBy always executes even if FFI calls throw
- Add clarifying comment in handleWindowCloseButton for single-tab
  audit dialog flow

* fix(terminal): fix session reconnect ID mismatch and tab close race condition

Remap surviving persistent sessions to client-requested terminal IDs on
reconnect, preventing new shell creation when IDs are non-contiguous.
Snapshot peerTabCount before async operations in _closeTab to avoid race
with concurrent _closeAllTabs clearing the tab controller.
Remove debug log statements.

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-21 11:06:13 +08:00
fufesou
483fe80308 fix(terminal): fix new tab auto-focus and NaN error on data before layout (#14357)
- Fix new tab not auto-focusing: add FocusNode to TerminalView and
  request focus when tab is selected via tab state listener
- Fix NaN error when data arrives before terminal view layout: buffer
  output data until terminal view has valid dimensions, flush on first
  valid resize callback

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-20 14:44:25 +08:00
fufesou
34ceeac36e fix(terminal): fix tabKey parsing for peerIds containing underscores (#14354)
Terminal tab keys use the format "peerId_terminalId". The previous code
used split('_')[0] or startsWith('$peerId_') to extract the peerId,
which breaks when the peerId itself contains underscores.

This can happen in two scenarios:
- Hostname-based ID: when OPTION_ALLOW_HOSTNAME_AS_ID is enabled, the
  peerId is derived from the system hostname, which commonly contains
  underscores (e.g. "my_dev_machine").
- Custom ID: the validation regex ^[a-zA-Z][\w-]{5,15}$ allows
  underscores since \w matches [a-zA-Z0-9_], so IDs like "my_dev_01"
  are valid.

Fix all three parsing sites in terminal_tab_page.dart to use
lastIndexOf('_'), which is safe because terminalId is always a plain
integer with no underscores.
2026-02-19 23:45:06 +08:00
cui
20f11018ce fix: lte should be lt like in linux.rs (#14344) 2026-02-19 22:24:32 +08:00
Nicola Spieser Buiss
9345fb754a fix: correct typos and improve code clarity (#14341)
- Fix 'clipbard' typos in clipboard.rs (function names, comments, strings)
- Fix 'seperate' typo in x11/server.rs comment
- Replace !is_ok() with idiomatic is_err() in updater.rs
- Fix double backtick typo in updater.rs comment

Co-authored-by: Ocean <ocean@Mac-mini-von-Ocean.local>
2026-02-17 14:29:50 +08:00
fufesou
779b7aaf02 feat(wayland): keyboard mode, legacy translate (#14317)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-15 16:43:21 +08:00
21pages
b268aa1061 Fix some single device multiple ids scenarios on MacOS (#14196)
* fix(macos): sync config to root when root config is empty

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

* fix(server): gate startup on initial config sync; document CheckIfResendPk limitation

  - wait up to 3s for initial root->local config sync before starting server services
  - continue startup when timeout is hit, while keeping sync/watch running in background
  - avoid blocking non-server process startup
  - clarify that CheckIfResendPk only re-registers PK for current ID and does not solve multi-ID when root uses a non-default mac-generated ID

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-02-15 16:12:26 +08:00
Vance
40f86fa639 fix(mobile): account for safe area padding in canvas size calculation (#14285)
* fix(mobile): account for safe area padding in canvas size calculation

* fix(mobile): differentiate safe area handling for portrait vs landscape

* refact(ios): Simple refactor

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

* fix(ios): canvas getSize, test -> Android

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

* fix: comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-02-15 14:52:27 +08:00
rustdesk
980bc11e68 update common 2026-02-14 17:48:53 +08:00
Shaikh Naasir
85db677982 docs: fix typos in clipboard documentation (#13521)
Signed-off-by: Naasir <yoursdeveloper@protonmail.com>
2026-02-14 01:06:25 +08:00
fufesou
2842315b1d Fix/linux shortcuts inhibit (#14302)
* feat: Inhibit system shortcuts on Linux

Fixes #13013.

Signed-off-by: Max von Forell <max@vonforell.de>

* fix(linux): shortcuts inhibit

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

---------

Signed-off-by: Max von Forell <max@vonforell.de>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Max von Forell <max@vonforell.de>
2026-02-11 16:11:47 +08:00
fufesou
6c541f7bfd fix(xdo): deb, libxdo3 | libxdo4 (#14314)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-11 16:11:15 +08:00
VenusGirl❤
067fab2b73 Update Korean (#14298)
Correct spacing and spelling
2026-02-10 18:48:30 +08:00
fufesou
de6bf9dc7e fix(ios): Add defensive timer cancellation for keyboard visibility (#14301)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-09 15:54:22 +08:00
fufesou
54eae37038 fix(ios): workaround physical keyboard after virtual keyboard hidden (#14207)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-09 00:36:25 +08:00
Hugo Breda
0118e16132 PT-BR language update (#14295)
* PT-BR language update

@rustdesk
Please merge. Thanks

* Update ptbr.rs

* Update ptbr.rs

Please submit, i will get back soon and finish all other stuff.

* PT-BR language update

Completed all missing PT-BR translations.
2026-02-09 00:31:47 +08:00
fufesou
626a091f55 fix(translation): OIDC, Continue with (#14271)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-06 14:18:48 +08:00
Daniel Marschall
4fa5e99e65 Remove unused option_env!(...) (#13959) 2026-02-03 20:55:34 +08:00
bilimiyorum
5ee9dcf42d Update tr.rs (#14160)
The previous PR was reverted due to an incorrect file path. This PR applies the same updates to src/lang/tr.rs.
2026-02-02 22:18:36 +08:00
Copilot
6306f83316 Fix non-link text color in dialogs with links for dark theme (#14220)
* Initial plan

* Fix dialog text color for dark theme with links

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Keep original link color (blue), only fix non-link text color

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* fix: dialog text color in dark theme

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-02-01 12:18:07 +08:00
XLion
96075fdf49 Update tw.rs (#14138) 2026-01-31 16:38:09 +08:00
Copilot
8c6dcf53a6 iOS terminal: Add touch swipe and floating back button for exit (#14208)
* Initial plan

* Add iOS edge swipe gesture to exit terminal session

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Improve iOS edge swipe gesture with responsive thresholds and better gesture handling

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Fix: Reset _swipeCurrentX in onHorizontalDragStart to prevent stale state

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Add trackpad support documentation for iOS edge swipe gesture

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Add iOS-style circular back button to terminal page

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Remove trackpad support documentation - not needed with back button

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* Filter edge swipe gesture to touch-only input (exclude mouse/trackpad)

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* fix: missing import

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

* fix(ios): terminal swip exit gesture

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

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

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-31 16:37:45 +08:00
fufesou
e1b1a927b8 fix(ios): capsLock, workaround #5871 (#14194)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-30 17:32:18 +08:00
fufesou
1e6bfa7bb1 fix(iPad): Magic Mouse, click (#14188)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-29 15:25:44 +08:00
fufesou
79ef4c4501 Copilot/fix action run error (#14186)
* Initial plan

* Fix macOS build: Remove @available check causing linker error

The @available check in GetDisplayName was causing the linker to look for
__isPlatformVersionAtLeast symbol which is not available when targeting
macOS 10.14. Since this function is only used for logging, we simplify it
to return "Unknown" for all displays, avoiding the runtime availability check.

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>

* fix(macOS): ___isPlatformVersionAtLeast is not available in macOS 10.14

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
2026-01-28 17:44:17 +08:00
twprh
5f3ceef592 Update de.rs (#14139)
zum Zeitpunkt der Anzeige ist der Datenschutz aktiviert bzw. schon beendet.

alternativ ginge auch:

Datenschutzmodus wurde aktiviert  
bzw.
 Datenschutzmodus wurde beendet
2026-01-28 15:16:27 +08:00
Lynilia
1a90e6b6c7 Update fr.rs (#14151) 2026-01-28 15:16:06 +08:00
John Fowler
f112d097dc Replacing incorrect quotation marks (#14144)
* Update Hungarian translations in hu.rs

Translation of new strings and some fixes.
John Fowler.

* Escape quotes in Hungarian language strings

Replacing Hungarian quotation marks

* Update Hungarian translations for various terms

Upload a new translation (hu.rs) file.
2026-01-28 15:15:29 +08:00
ThallesWS
45cab7f808 fix issue: #13911 'Double Click' bug on iPad with Magic Mouse (#14086)
* fix issue: #13911 'Double Click' bug on iPad with Magic Mouse

* remote_input.dart comments - gestures.dart organization and clean states of all interrupted gestures
2026-01-28 15:14:06 +08:00
fufesou
216ec9d52b fix(terminal): ios delete (#14147)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-28 15:12:42 +08:00
fufesou
56a8f6b97b fix(iOS): Unexpected mouse movement to (0,0) on idle (#14180)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-28 15:11:44 +08:00
Bin Li
c76d10a438 feat(macos): initial privacy mode support [a simple try] (#14102)
* feat(macos): add privacy mode support for macOS

## Summary
Add privacy mode functionality for macOS platform, allowing remote
desktop sessions to hide the screen content from local users.

## Changes

### Core Implementation (src/platform/macos.mm)
- Implement screen blackout using CGDisplayGammaTable API
- Implement input blocking using CGEventTap to intercept keyboard/mouse
- Store and restore original gamma values for proper cleanup

### Privacy Mode Integration (src/privacy_mode.rs, src/privacy_mode/macos.rs)
- Add macOS privacy mode implementation with PrivacyMode trait
- Register macOS privacy mode in PRIVACY_MODE_CREATOR
- Set DEFAULT_PRIVACY_MODE_IMPL for macOS platform
- Implement get_supported_privacy_mode_impl() for macOS

### Connection Handling (src/server/connection.rs)
- Add supported_privacy_mode_impl to platform_additions for macOS
- Enable privacy mode toggle in client UI when connecting via LAN IP

### Localization (src/lang/*.rs)
- Add "privacy_mode_impl_macos_tip" translation for en/cn/tw

## Safety & Security
- Implements Drop trait to ensure cleanup on normal exit
- macOS system automatically restores gamma table on process termination
- CGEventTap is automatically released when process terminates
- Tested with SIGKILL to verify crash recovery

## Testing
- Verified privacy mode toggle works via both ID and LAN IP connection
- Verified screen recovery after process crash (kill -9)
- Verified input restoration after process termination

* refactor: use existing 'Privacy mode' translation key

* refactor: rename gamma channel variables for better readability - rename r/g/b to red/green/blue to avoid variable shadowing confusion

* fix: add error handling for gamma table restoration with fallback to system reset

* fix: add error handling for CGEventTapCreate failure in privacy mode

* fix: only set display to black if original gamma was saved successfully

* fix: add error handling for CGSetDisplayTransferByTable when setting display to black

* fix: improve event tap callback to properly distinguish remote input from local input

* fix: missing macos.rs

* Fix: Add display validation before restoring gamma values

* Fix: Add mutex lock for thread safety in MacSetPrivacyMode

* Fix: Handle return values and add missing mouse events in macos privacy mode

* fix: only set conn_id after privacy mode is successfully turned on

* fix: reimplement privacy mode with stable display identification

Address code review concern: original gamma values stored with DisplayID
as key could become stale if display list changes between privacy mode
activations (e.g., display reconnected with different ID).

Solution:
- Use UUID instead of DisplayID as storage key (stable across reconnections)
- Clear g_originalGammas when privacy mode is turned off
- Register CGDisplayReconfigurationCallback to handle hot-plug events
- Validate display state via FindDisplayIdByUUID() before restoration

Key features:
- UUID-based display identification (stable across reconnections)
- Hot-plug support via CGDisplayReconfigurationCallback
- EventTap auto re-enable on system timeout
- Fallback to CGDisplayRestoreColorSyncSettings() for recovery
- Detailed error logging with display name/ID/UUID

* fix: ensure EventTap runs on main thread and improve gamma restore error handling

- Add SetupEventTapOnMainThread() to create EventTap on main thread using dispatch_sync, avoiding potential issues when called from background threads

- Add TeardownEventTapOnMainThread() for consistent cleanup on main thread

- Check [NSThread isMainThread] to avoid deadlock when already on main thread

- Add error tracking for gamma restoration during cleanup

- Use CGDisplayRestoreColorSyncSettings() as fallback when individual gamma restoration fails

* fix: remove invalid eventMask bits that caused undefined behavior in input blocking

* fix: address code review comments for macos privacy mode implementation

Changes to src/privacy_mode/macos.rs:
- Add check_on_conn_id() in turn_on_privacy() to prevent duplicate activation
- Add check_off_conn_id() in turn_off_privacy() to validate connection ID
- Add self.conn_id = 0 in clear() to reset connection state

Changes to src/platform/macos.mm:
- Add link comment for ENIGO_INPUT_EXTRA_VALUE referencing libs/enigo/src/macos/macos_impl.rs
- Fix NSLog format string mismatch (5 placeholders vs 4 values)
- Make ApplyBlackoutToDisplay() return bool for proper error handling
- Return false when UUID is empty since privacy mode requires ALL displays
- Add else branches with logging for:
  - CGGetDisplayTransferByTable failures
  - Zero gamma table capacity (not supported)
  - Zero blackout capacity
- Remove unused g_uuidToDisplayId variable (was only written, never read)

* fix(macos): add early return with privacy mode exit on display hotplug failures

Why large-scale changes are needed:

The code review suggested adding early return when errors occur in
DisplayReconfigurationCallback. However, simply returning early is not
enough - when a newly connected display cannot be blacked out, we must
exit privacy mode entirely to maintain security guarantees.

The challenge is that DisplayReconfigurationCallback already holds
g_privacyModeMutex, so calling MacSetPrivacyMode(false) directly would
cause a deadlock. This necessitated:

1. Extract TurnOffPrivacyModeInternal() - a lock-free internal function
   that can be safely called from within the callback
2. Refactor MacSetPrivacyMode(false) branch to use this internal function
3. Add early returns with TurnOffPrivacyModeInternal() calls at each
   failure point in DisplayReconfigurationCallback

Changes in DisplayReconfigurationCallback:
- UUID empty: log + exit privacy mode + early return
- Gamma table capacity zero: log + exit privacy mode + early return
- CGGetDisplayTransferByTable fails: log + exit privacy mode + early return
- ApplyBlackoutToDisplay fails: log + exit privacy mode + early return

* fix(macos): address code review feedback and improve privacy mode stability

Code Review Fixes:
- Add detailed comments for potential deadlock scenarios in dispatch_sync
  with g_privacyModeMutex (SetupEventTapOnMainThread/TeardownEventTapOnMainThread)
- Use async dispatch for privacy mode shutdown from DisplayReconfigurationCallback
  to avoid unregistering callback from within itself
- Extract RestoreAllGammas() helper function to reduce code duplication
- Fix Drop implementation in macos.rs to call self.clear() for consistency
- Add comment explaining why _state parameter is ignored on macOS
- Define DISPLAY_RECONFIG_MONITOR_DURATION_MS and GAMMA_CHECK_INTERVAL_MS constants
- Add gamma restoration when UUID retrieval fails during privacy mode activation

Privacy Mode Stability Improvements (Continuous Resolution Changes):
- Implement continuous gamma value monitoring with timer polling after display
  reconfiguration to handle rapid successive resolution changes
- Monitor gamma values every 200ms for 5 seconds after each resolution change
- Automatically reapply blackout if system (ColorSync) restores gamma
- Add IsDisplayBlackedOut() to detect if display gamma has been restored
- Use timestamp-based debouncing: monitoring period automatically extends
  when new reconfig events occur during active monitoring
- Ensure blackout remains effective even under continuous resolution changes
  where macOS may asynchronously restore gamma values multiple times

This ensures privacy mode remains stable and effective when users rapidly
change display resolution multiple times in succession.

---------

Co-authored-by: libin <libin.chat@outlook.com>
2026-01-27 16:38:37 +08:00
Alex Rijckaert
f05f2178e5 Update Dutch translations (#14136) 2026-01-26 14:16:21 +08:00
Hugo Breda
226d7417b2 PT-BR language update (#14135)
* PT-BR language update

@rustdesk
Please merge. Thanks

* Update ptbr.rs

* Update ptbr.rs

Please submit, i will get back soon and finish all other stuff.
2026-01-26 14:15:58 +08:00
bovirus
b0c8e65c6e Italian language update (#14129) 2026-01-26 14:15:45 +08:00
RustDesk
4ae577c3c4 Revert "Updated tr.rs (#14115)" (#14158)
This reverts commit 204e81a700.
2026-01-26 14:14:35 +08:00
bilimiyorum
204e81a700 Updated tr.rs (#14115)
Translation improvements have been made.
2026-01-26 14:11:58 +08:00
hatterp
1f35830570 Update pl.rs (#14112)
updated PL translation
2026-01-26 14:11:41 +08:00
VenusGirl❤
6b334f2977 Update ko.rs (#14110)
Update Korean
2026-01-25 16:37:34 +08:00
Mr-Update
0dc3c12aa5 Update de.rs (#14108) 2026-01-24 12:50:18 +08:00
RustDesk
ceffcce20e fix hide-tray=Y causing The application “RustDesk.app” is not open anymore. https://github.com/rustdesk/rustdesk/discussions/10210 (#14127) 2026-01-23 19:09:33 +08:00
21pages
e4b06dadf5 auto retry on offline when already connected (#14124)
When controlled peer is reconnecting after signout/switch user, auto retry for 30s (matches server's peer offline threshold) instead of immediately showing "Remote desktop is offline" error.

Ref: https://github.com/rustdesk/rustdesk/discussions/14048

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-23 15:05:11 +08:00
Yavuz Selim YAZICI
087eb55299 Update tr.rs, Missing Turkish translations added (#14103)
* Update tr.rs

* Update tr.rs

---------

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-01-22 14:15:14 +08:00
bilimiyorum
341eb0c671 Updated tr.rs (#14100)
New string entries
Minor typo corrections
2026-01-22 14:13:38 +08:00
solokot
43b39102a4 Update ru.rs (#14099) 2026-01-22 14:12:26 +08:00
fufesou
be4bbd018d fix(install): linux xdo (#14096)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-21 20:43:15 +08:00
RustDesk
21a7cef98a keep-awake-during-incoming-sessions (#14082)
* keep-awake-during-incoming-sessions

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

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

* Update flutter/lib/common.dart

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

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

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

* Update common.dart

* wakelock

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

* fix build

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

* Update server_model.dart

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-01-21 16:25:57 +08:00
fufesou
a6724b1c07 fix: build (#14093)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-20 22:53:18 +08:00
Cody Kim
7437593ee7 Update ko.rs (#14055)
* update: correct Korean translations (typo/grammar)

- typo: 인중 -> 인증
- grammar: 중 입니다 -> 중입니다

Signed-off-by: 0-Chan <kyc4421194@gmail.com>

* update: improve Korean translations

Signed-off-by: 0-Chan <kyc4421194@gmail.com>

---------

Signed-off-by: 0-Chan <kyc4421194@gmail.com>
2026-01-20 17:08:54 +08:00
VenusGirl❤
f21829b075 Update Korean (#14057) 2026-01-20 17:08:02 +08:00
hatterp
b4f60e6057 Update pl.rs (#14054)
improve Polish translation

Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-01-19 13:41:10 +08:00
hatterp
b9ebddff0c Update pl.rs (#14053)
Add and improve Polish translation.
2026-01-18 19:34:26 +08:00
hatterp
a2243484a3 Update README-PL.md (#14052) 2026-01-17 18:31:41 +08:00
21pages
c4a9835ae5 change quick support filename detection (#14050)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-15 13:47:39 +08:00
Alex Rijckaert
92ad279324 Dutch Translation up to date (#14033) 2026-01-14 13:05:01 +08:00
Kratos
7276025cf9 Update hu.rs (#14032)
Fix translated strings.
2026-01-13 11:00:29 +08:00
minh
9808d585cf Update vi.rs file (#14027) 2026-01-13 11:00:16 +08:00
John Fowler
dab9ed711c Update Hungarian translations in hu.rs (#14014)
Translation of new strings and some fixes.
John Fowler.
2026-01-12 20:57:05 +08:00
Mr-Update
b27a93fc77 Update de.rs (#14013) 2026-01-12 20:56:50 +08:00
Lynilia
e3f66973b7 Update fr.rs (#14012) 2026-01-12 20:56:35 +08:00
bilimiyorum
21529d6ca2 Current tr.rs (#14008)
New string entries
2026-01-12 20:56:19 +08:00
solokot
775b0a3c93 Update ru.rs (#14004) 2026-01-12 20:56:02 +08:00
Anatolij Vasilev
070d4d029f synchronized german translation with the current english readme (#14001) 2026-01-12 10:59:11 +08:00
bovirus
5355702e9c Italian language update (#13998) 2026-01-12 10:58:42 +08:00
VenusGirl❤
a97997952d Update Korean (#13996)
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2026-01-12 10:58:23 +08:00
Sunev
b0c12bd86b Update signing conditions for rustdesk files (#14010)
Now ```env.SIGN_BASE_URL``` would never be ```''```, yet could be ```'-2'``` while ```secrets.SIGN_BASE_URL``` was undefined.
2026-01-10 15:29:59 +08:00
fufesou
82fcab26b1 refact(sign): skip signed files (#14006)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-10 02:01:32 +08:00
fufesou
f3bbcc4f55 refact(sign): skip signed files (#14005)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-01-10 00:55:00 +08:00
21pages
98362eaca0 add Changelog link in update help card (#13997)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-01-09 15:34:51 +08:00
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
alonginwind
d4410e78e2 feat(ui): show alias instead of peerId in terminal tab label (#13332) 2025-10-29 16:11:20 +08:00
fufesou
e3fcc6cce3 fix: file transfer, auto start on reconnect (#13329)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-10-29 15:15:05 +08:00
fufesou
265d08fc3b fix: mobile remove "Scale custom" (#13323)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-10-28 20:25:33 +08:00
21pages
7c8329c5c6 fix mac hwcodec check (#13320)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-28 16:29:42 +08:00
Kendall
d3947c9a19 Fix typo in Spanish translation for downloading message (#13289) 2025-10-27 17:09:42 +08:00
Luca-rickrolled-himself
cd99331668 Add Romanian Locale (#13270)
* Create CODE_OF_CONDUCT-RO.md

* Create CONTRIBUTING-RO.md

* Create SECURITY-RO.md

* Create README-RO.md

* Update README.md
2025-10-27 16:52:36 +08:00
Re*Index. (ot_inc)
472e18b10a Update Japanese (#13268) 2025-10-27 16:52:22 +08:00
Ivan Beà
d443f5de28 Update catalan translation ca.rs (#13267)
Update catalan translation
2025-10-27 16:52:10 +08:00
21pages
3242d132f6 opt ui of Windows session dialog (#13303)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-27 16:51:30 +08:00
Tomppaa
e66d2facd4 Create fi.rs (#13212)
Please add Finnish language rustdesk.
2025-10-26 21:28:56 +08:00
Tomppaa
f15b8dc0da Update lang.rs (#13259)
added finnish language.
2025-10-26 21:28:35 +08:00
Nguyễn Quý Hy
3275824aec Allow flipping sort order in mobile app's file transfer (#13273)
* Allow flipping sort order in mobile app's file transfer

Signed-off-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>

* Change ascending to be non-nullable

Signed-off-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>

* Revert file_model change

Signed-off-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>

---------

Signed-off-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>
2025-10-25 21:10:26 +08:00
21pages
965cb704ec add try catch on android setCodecInfo in case of unexpected crash (#13280)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-24 21:04:18 +08:00
fufesou
938e165470 fix: save frame, LateInitializationError (#13265)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-10-24 17:20:56 +08:00
esterTion
9058ef3344 ios: Enable file sharing and document browser support (#13226) 2025-10-23 15:58:50 +08:00
fufesou
ed39cc3038 fix: video service, wait timeout (#13208)
Use multiple frame fetched notifiers.

Signed-off-by: fufesou <linlong1266@gmail.com>
2025-10-22 13:19:08 +08:00
21pages
a77752c4cb fix tab lable translation (#13240)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-21 15:39:52 +08:00
21pages
c9940957f0 fix camera large error log (#13227)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-10-20 13:23:41 +08:00
Andrzej Rudnik
c90d72d720 Update pl.rs (#13210) 2025-10-19 14:19:53 +08:00
XLion
2c30bd9d24 Update tw.rs (#13203) 2025-10-18 16:39:08 +08:00
rustdesk
f2dc8e21a8 build 61 2025-10-18 08:50:07 +08:00
275 changed files with 29340 additions and 5439 deletions

View File

@@ -1,56 +0,0 @@
You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to analyze and improve the instructions for Claude Code.
Follow these steps carefully:
1. Analysis Phase:
Review the chat history in your context window.
Then, examine the current Claude instructions, commands and config
<claude_instructions>
/CLAUDE.md
/.claude/commands/*
**/CLAUDE.md
.claude/settings.json
.claude/settings.local.json
</claude_instructions>
Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for:
- Inconsistencies in Claude's responses
- Misunderstandings of user requests
- Areas where Claude could provide more detailed or accurate information
- Opportunities to enhance Claude's ability to handle specific types of queries or tasks
- New commands or improvements to a commands name, function or response
- Permissions and MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them for the command to work
2. Interaction Phase:
Present your findings and improvement ideas to the human. For each suggestion:
a) Explain the current issue you've identified
b) Propose a specific change or addition to the instructions
c) Describe how this change would improve Claude's performance
Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the implementation phase. If not, refine your suggestion or move on to the next idea.
3. Implementation Phase:
For each approved change:
a) Clearly state the section of the instructions you're modifying
b) Present the new or modified text for that section
c) Explain how this change addresses the issue identified in the analysis phase
4. Output Format:
Present your final output in the following structure:
<analysis>
[List the issues identified and potential improvements]
</analysis>
<improvements>
[For each approved improvement:
1. Section being modified
2. New or modified instruction text
3. Explanation of how this addresses the identified issue]
</improvements>
<final_instructions>
[Present the complete, updated set of instructions for Claude, incorporating all approved changes]
</final_instructions>
Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your implementations.

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"
NDK_VERSION: "r27c"
VERSION: "1.4.6"
NDK_VERSION: "r28c"
#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:
@@ -234,11 +234,11 @@ jobs:
path: rustdesk
- name: Sign rustdesk files
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
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
@@ -266,10 +266,10 @@ jobs:
sha256sum ../../SignOutput/rustdesk-*.msi
- name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
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
@@ -400,11 +400,11 @@ jobs:
path: Release
- name: Sign rustdesk files
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
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
@@ -418,10 +418,10 @@ jobs:
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe
- name: Sign rustdesk self-extracted file
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
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.6"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -1,15 +0,0 @@
name: Publish to WinGet
on:
release:
types: [released]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: RustDesk.RustDesk
version: "1.4.3"
release-tag: "1.4.3"
token: ${{ secrets.WINGET_TOKEN }}

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.vscode
.idea
.DS_Store
.env
libsciter-gtk.so
src/ui/inline.rs
extractor

62
AGENTS.md Normal file
View File

@@ -0,0 +1,62 @@
# RustDesk Guide
## Project Layout
### Directory Structure
* `src/` Rust app
* `src/server/` audio / clipboard / input / video / network
* `src/platform/` platform-specific code
* `src/ui/` legacy Sciter UI (deprecated)
* `flutter/` current UI
* `libs/hbb_common/` config / proto / shared utils
* `libs/scrap/` screen capture
* `libs/enigo/` input control
* `libs/clipboard/` clipboard
* `libs/hbb_common/src/config.rs` all options
### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
- **Modern UI**: Flutter-based - files in `flutter/`
- Desktop: `flutter/lib/desktop/`
- Mobile: `flutter/lib/mobile/`
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
## Rust Rules
* Avoid `unwrap()` / `expect()` in production code.
* Exceptions:
* tests;
* lock acquisition where failure means poisoning, not normal control flow.
* Otherwise prefer `Result` + `?` or explicit handling.
* Do not ignore errors silently.
* Avoid unnecessary `.clone()`.
* Prefer borrowing when practical.
* Do not add dependencies unless needed.
* Keep code simple and idiomatic.
## Tokio Rules
* Assume a Tokio runtime already exists.
* Never create nested runtimes.
* Never call `Runtime::block_on()` inside Tokio / async code.
* Do not hide runtime creation inside helpers or libraries.
* Do not hold locks across `.await`.
* Prefer `.await`, `tokio::spawn`, channels.
* Use `spawn_blocking` or dedicated threads for blocking work.
* Do not use `std::thread::sleep()` in async code.
## Editing Hygiene
* Change only what is required.
* Prefer the smallest valid diff.
* Do not refactor unrelated code.
* Do not make formatting-only changes.
* Keep naming/style consistent with nearby code.

View File

@@ -1,91 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build Commands
- `cargo run` - Build and run the desktop application (requires libsciter library)
- `python3 build.py --flutter` - Build Flutter version (desktop)
- `python3 build.py --flutter --release` - Build Flutter version in release mode
- `python3 build.py --hwcodec` - Build with hardware codec support
- `python3 build.py --vram` - Build with VRAM feature (Windows only)
- `cargo build --release` - Build Rust binary in release mode
- `cargo build --features hwcodec` - Build with specific features
### Flutter Mobile Commands
- `cd flutter && flutter build android` - Build Android APK
- `cd flutter && flutter build ios` - Build iOS app
- `cd flutter && flutter run` - Run Flutter app in development mode
- `cd flutter && flutter test` - Run Flutter tests
### Testing
- `cargo test` - Run Rust tests
- `cd flutter && flutter test` - Run Flutter tests
### Platform-Specific Build Scripts
- `flutter/build_android.sh` - Android build script
- `flutter/build_ios.sh` - iOS build script
- `flutter/build_fdroid.sh` - F-Droid build script
## Project Architecture
### Directory Structure
- **`src/`** - Main Rust application code
- `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead)
- `src/server/` - Audio/clipboard/input/video services and network connections
- `src/client.rs` - Peer connection handling
- `src/platform/` - Platform-specific code
- **`flutter/`** - Flutter UI code for desktop and mobile
- **`libs/`** - Core libraries
- `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities
- `libs/scrap/` - Screen capture functionality
- `libs/enigo/` - Platform-specific keyboard/mouse control
- `libs/clipboard/` - Cross-platform clipboard implementation
### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
- **Modern UI**: Flutter-based - files in `flutter/`
- Desktop: `flutter/lib/desktop/`
- Mobile: `flutter/lib/mobile/`
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
## Important Build Notes
### Dependencies
- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom`
- Set `VCPKG_ROOT` environment variable
- Download appropriate Sciter library for legacy UI support
### Ignore Patterns
When working with files, ignore these directories:
- `target/` - Rust build artifacts
- `flutter/build/` - Flutter build output
- `flutter/.dart_tool/` - Flutter tooling files
### Cross-Platform Considerations
- Windows builds require additional DLLs and virtual display drivers
- macOS builds need proper signing and notarization for distribution
- Linux builds support multiple package formats (deb, rpm, AppImage)
- Mobile builds require platform-specific toolchains (Android SDK, Xcode)
### Feature Flags
- `hwcodec` - Hardware video encoding/decoding
- `vram` - VRAM optimization (Windows only)
- `flutter` - Enable Flutter UI
- `unix-file-copy-paste` - Unix file clipboard support
- `screencapturekit` - macOS ScreenCaptureKit (macOS only)
### Config
All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types:
- Settings
- Local
- Display
- Built-in
AGENTS.md

1743
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.6"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -76,13 +76,14 @@ crossbeam-queue = "0.3"
hex = "0.4"
chrono = "0.4"
cidr-utils = "0.5"
libloading = "0.8"
fon = "0.6"
zip = "0.6"
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 +122,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"
@@ -150,7 +160,7 @@ piet-coregraphics = "0.6"
foreign-types = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
tray-icon = { git = "https://github.com/tauri-apps/tray-icon" }
tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" }
tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" }
image = "0.24"
@@ -165,14 +175,8 @@ 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]
libxdo-sys = "0.11"
psimple = { package = "libpulse-simple-binding", version = "2.27" }
pulse = { package = "libpulse-binding", version = "2.27" }
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
@@ -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"
@@ -201,6 +207,11 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
exclude = ["vdi/host", "examples/custom_plugin"]
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
[patch.crates-io]
libxdo-sys = { path = "libs/libxdo-sys-stub" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
ProductName = "RustDesk"
@@ -234,3 +245,6 @@ panic = 'abort'
strip = true
#opt-level = 'z' # only have smaller size after strip
rpath = true
[profile.dev]
debug = 1

1
GEMINI.md Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -4,7 +4,7 @@
<a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>]<br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
</p>

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.3
version: 1.4.6
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.6
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -299,7 +299,7 @@ Version: %s
Architecture: %s
Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com
Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.

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

143
docs/CODE_OF_CONDUCT-FR.md Normal file
View File

@@ -0,0 +1,143 @@
# Code de conduite des contributeurs
## Notre engagement
En tant que membres, contributeurs et responsables, nous nous engageons à faire
de la participation à notre communauté une expérience exempte de harcèlement pour
tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou
invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité
et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut
socio-économique, de la nationalité, de l'apparence personnelle, de la race, de
la religion ou de l'identité et de l'orientation sexuelle.
Nous nous engageons à agir et à interagir de manière à contribuer à une
communauté ouverte, accueillante, diversifiée, inclusive et saine.
## Nos standards
Exemples de comportements qui contribuent à un environnement positif pour notre
communauté :
* Faire preuve d'empathie et de bienveillance envers les autres
* Respecter les opinions, les points de vue et les expériences différents
* Donner et accepter gracieusement les retours constructifs
* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos
erreurs et apprendre de l'expérience
* Se concentrer sur ce qui est le mieux non seulement pour nous en tant
qu'individus, mais pour l'ensemble de la communauté
Exemples de comportements inacceptables :
* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou
avances sexuelles de quelque nature que ce soit
* Le trolling, les commentaires insultants ou désobligeants, et les attaques
personnelles ou politiques
* Le harcèlement public ou privé
* La publication d'informations privées d'autrui, telles qu'une adresse physique
ou électronique, sans autorisation explicite
* Tout autre comportement qui pourrait raisonnablement être considéré comme
inapproprié dans un cadre professionnel
## Responsabilités en matière d'application
Les responsables de la communauté sont chargés de clarifier et d'appliquer nos
standards de comportement acceptable et prendront des mesures correctives
appropriées et équitables en réponse à tout comportement qu'ils jugent
inapproprié, menaçant, offensant ou nuisible.
Les responsables de la communauté ont le droit et la responsabilité de
supprimer, modifier ou rejeter les commentaires, commits, code, modifications
du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de
conduite, et communiqueront les raisons de leurs décisions de modération le cas
échéant.
## Portée
Ce Code de conduite s'applique dans tous les espaces communautaires, et
s'applique également lorsqu'une personne représente officiellement la communauté
dans les espaces publics. Les exemples de représentation de notre communauté
incluent l'utilisation d'une adresse e-mail officielle, la publication via un
compte de réseau social officiel, ou le fait d'agir en tant que représentant
désigné lors d'un événement en ligne ou hors ligne.
## Application
Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent
être signalés aux responsables de la communauté chargés de l'application à
[info@rustdesk.com](mailto:info@rustdesk.com).
Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et
équitable.
Tous les responsables de la communauté sont tenus de respecter la vie privée et
la sécurité de la personne ayant signalé un incident.
## Directives d'application
Les responsables de la communauté suivront ces Directives d'impact communautaire
pour déterminer les conséquences de toute action qu'ils jugent en violation de ce
Code de conduite :
### 1. Correction
**Impact communautaire** : Utilisation d'un langage inapproprié ou autre
comportement jugé non professionnel ou indésirable dans la communauté.
**Conséquence** : Un avertissement écrit et privé de la part des responsables de
la communauté, expliquant la nature de la violation et pourquoi le comportement
était inapproprié. Des excuses publiques peuvent être demandées.
### 2. Avertissement
**Impact communautaire** : Une violation par un incident isolé ou une série
d'actions.
**Conséquence** : Un avertissement avec des conséquences en cas de comportement
répété. Aucune interaction avec les personnes impliquées, y compris les
interactions non sollicitées avec les personnes chargées d'appliquer le Code de
conduite, pendant une période déterminée. Cela inclut d'éviter les interactions
dans les espaces communautaires ainsi que dans les canaux externes comme les
réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion
temporaire ou permanente.
### 3. Exclusion temporaire
**Impact communautaire** : Une violation grave des standards communautaires, y
compris un comportement inapproprié persistant.
**Conséquence** : Une exclusion temporaire de toute interaction ou communication
publique avec la communauté pendant une période déterminée. Aucune interaction
publique ou privée avec les personnes impliquées, y compris les interactions non
sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est
autorisée pendant cette période. Le non-respect de ces conditions peut entraîner
une exclusion permanente.
### 4. Exclusion permanente
**Impact communautaire** : Démontrer un schéma de violation des standards
communautaires, y compris un comportement inapproprié persistant, le harcèlement
d'une personne, ou une agression envers des catégories de personnes ou leur
dénigrement.
**Conséquence** : Une exclusion permanente de toute interaction publique au sein
de la communauté.
## Attribution
Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0,
disponible à l'adresse
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Les Directives d'impact communautaire ont été inspirées par
[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC].
Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la
FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions
sont disponibles à l'adresse
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

@@ -0,0 +1,85 @@
# Codul de Conduită al Contributorilor
## Angajamentul Nostru
Noi, ca membri, contribuitori și lideri, ne angajăm să facem ca participarea în comunitatea noastră să fie o experiență fără hărțuire pentru toată lumea, indiferent de vârstă, dimensiunea corpului, dizabilități vizibile sau invizibile, etnie, caracteristici sexuale, identitate și exprimare de gen, nivel de experiență, educație, statut socio-economic, naționalitate, aspect personal, rasă, religie sau identitate și orientare sexuală.
Ne angajăm să acționăm și să interacționăm în moduri care contribuie la o comunitate deschisă, primitoare, diversă, incluzivă și sănătoasă.
## Standardele Noastre
Exemple de comportamente care contribuie la un mediu pozitiv pentru comunitatea noastră includ:
* Demonstrarea empatiei și a bunătății față de ceilalți
* Respectarea opiniilor, punctelor de vedere și experiențelor diferite
* Oferirea și acceptarea cu grație a feedback-ului constructiv
* Asumarea responsabilității și cererea de scuze celor afectați de greșelile noastre și învățarea din experiență
* Concentrarea pe ceea ce este cel mai bun nu doar pentru noi ca indivizi, ci pentru întreaga comunitate
Exemple de comportamente inacceptabile includ:
* Utilizarea limbajului sau imaginilor sexualizate, precum și atenția sau avansurile sexuale de orice fel
* Trollare, insulte sau comentarii denigratoare și atacuri personale sau politice
* Hărțuire publică sau privată
* Publicarea informațiilor private ale altora, cum ar fi adresa fizică sau de e-mail, fără permisiunea explicită
* Alte comportamente care ar putea fi considerate inadecvate într-un cadru profesional
## Responsabilități de Aplicare
Liderii comunității sunt responsabili pentru clarificarea și aplicarea standardelor noastre de comportament acceptabil și vor lua măsuri corective adecvate și echitabile ca răspuns la orice comportament pe care îl consideră inadecvat, amenințător, ofensator sau dăunător.
Liderii comunității au dreptul și responsabilitatea de a elimina, edita sau respinge comentarii, commit-uri, cod, editări wiki, probleme și alte contribuții care nu se aliniază acestui Cod de Conduită și vor comunica motivele pentru deciziile de moderare atunci când este cazul.
## Domeniu de Aplicare
Acest Cod de Conduită se aplică în toate spațiile comunității și se aplică și atunci când un individ reprezintă oficial comunitatea în spații publice.
Exemple de reprezentare a comunității includ utilizarea unei adrese de e-mail oficiale, postarea printr-un cont oficial de social media sau acționarea ca reprezentant desemnat la un eveniment online sau offline.
## Aplicare
Cazurile de comportament abuziv, hărțuitor sau altfel inacceptabil pot fi raportate liderilor comunității responsabili pentru aplicare la [info@rustdesk.com](mailto:info@rustdesk.com).
Toate plângerile vor fi revizuite și investigate prompt și corect.
Toți liderii comunității sunt obligați să respecte confidențialitatea și securitatea persoanei care raportează orice incident.
## Ghiduri de Aplicare
Liderii comunității vor urma aceste Ghiduri privind Impactul Comunității pentru a stabili consecințele pentru orice acțiune pe care o consideră o încălcare a acestui Cod de Conduită:
### 1. Corectare
**Impact asupra comunității**: Utilizarea limbajului neadecvat sau alte comportamente considerate neprofesionale sau nedorite în comunitate.
**Consecință**: O avertizare scrisă și privată din partea liderilor comunității, oferind claritate asupra naturii încălcării și o explicație despre motivul pentru care comportamentul a fost inadecvat. Poate fi cerută o scuză publică.
### 2. Avertisment
**Impact asupra comunității**: Încălcare printr-un incident singular sau o serie de acțiuni.
**Consecință**: Un avertisment cu consecințe pentru continuarea comportamentului. Nicio interacțiune cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, pentru o perioadă specificată. Aceasta include evitarea interacțiunilor în spațiile comunității, precum și pe canale externe, cum ar fi rețelele sociale. Încălcarea acestor termeni poate duce la o suspendare temporară sau permanentă.
### 3. Suspendare Temporară
**Impact asupra comunității**: O încălcare serioasă a standardelor comunității, inclusiv comportament neadecvat susținut.
**Consecință**: Suspendare temporară de la orice tip de interacțiune sau comunicare publică cu comunitatea pentru o perioadă specificată. Nicio interacțiune publică sau privată cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, nu este permisă în această perioadă. Încălcarea acestor termeni poate duce la o interdicție permanentă.
### 4. Interdicție Permanentă
**Impact asupra comunității**: Demonstrând un tipar de încălcare a standardelor comunității, inclusiv comportament neadecvat susținut, hărțuire a unei persoane sau agresiune față de sau denigrare a unor grupuri de persoane.
**Consecință**: Interdicție permanentă de la orice tip de interacțiune publică în cadrul comunității.
## Atribuire
Acest Cod de Conduită este adaptat din [Contributor Covenant][homepage], versiunea 2.0, disponibil la [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Ghidurile privind Impactul Comunității au fost inspirate de [scara de aplicare a codului de conduită Mozilla][Mozilla CoC].
Pentru răspunsuri la întrebări frecvente despre acest cod de conduită, vezi FAQ la [https://www.contributor-covenant.org/faq][FAQ]. Traduceri sunt disponibile la [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

55
docs/CONTRIBUTING-FR.md Normal file
View File

@@ -0,0 +1,55 @@
# Contribuer à RustDesk
RustDesk accueille les contributions de tous. Voici les directives si vous
envisagez de nous aider :
## Contributions
Les contributions à RustDesk ou à ses dépendances doivent être soumises sous
forme de pull requests GitHub. Chaque pull request sera examinée par un
contributeur principal (une personne ayant la permission d'intégrer des
correctifs) et sera soit intégrée dans la branche principale, soit accompagnée
de retours sur les modifications requises. Toutes les contributions doivent
suivre ce format, même celles des contributeurs principaux.
Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en
commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela
permet d'éviter les efforts en double de la part des contributeurs sur la même
issue.
## Liste de vérification pour les pull requests
- Partez de la branche master et, si nécessaire, effectuez un rebase sur la
branche master actuelle avant de soumettre votre pull request. Si elle ne
fusionne pas proprement avec master, il vous sera peut-être demandé de
rebaser vos modifications.
- Les commits doivent être aussi petits que possible, tout en s'assurant que
chaque commit est correct de manière indépendante (c.-à-d. que chaque commit
doit compiler et passer les tests).
- Les commits doivent être accompagnés d'une signature Developer Certificate of
Origin (http://developercertificate.org), indiquant que vous (et votre
employeur le cas échéant) acceptez d'être liés par les termes de la
[licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de
`git commit`.
- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne
spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une
revue dans la pull request ou un commentaire, ou vous pouvez demander une
revue par [e-mail](mailto:info@rustdesk.com).
- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité.
Pour des instructions git spécifiques, consultez le
[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
## Conduite
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
## Communication
Les contributeurs de RustDesk se retrouvent fréquemment sur
[Discord](https://discord.gg/nDceKgxnkV).

31
docs/CONTRIBUTING-RO.md Normal file
View File

@@ -0,0 +1,31 @@
# Contribuții la RustDesk
RustDesk primește cu plăcere contribuții din partea tuturor. Iată ghidurile dacă te gândești să ne ajuți:
## Contribuții
Contribuțiile la RustDesk sau la dependențele sale ar trebui făcute sub forma de pull request-uri pe GitHub. Fiecare pull request va fi revizuit de un contributor principal (cineva cu permisiunea de a aplica patch-uri) și fie va fi integrat în arborele principal, fie vor fi oferite sugestii pentru modificările necesare. Toate contribuțiile trebuie să urmeze acest format, chiar și cele ale contributorilor principali.
Dacă dorești să lucrezi la o problemă, te rugăm să o revendici mai întâi comentând pe GitHub issue-ul pe care vrei să lucrezi. Aceasta previne eforturi duplicate din partea contributorilor asupra aceleiași probleme.
## Lista de verificare pentru Pull Request
- Creează un branch din branch-ul `master` și, dacă este necesar, fă rebase la branch-ul `master` curent înainte de a trimite pull request-ul. Dacă nu se poate integra curat cu `master`, ți se poate cere să faci rebase la modificările tale.
- Commit-urile ar trebui să fie cât mai mici posibil, asigurând totodată că fiecare commit este corect independent (adică fiecare commit ar trebui să compileze și să treacă testele).
- Commit-urile trebuie să fie însoțite de un semnătura Developer Certificate of Origin (http://developercertificate.org), care indică faptul că tu (și angajatorul tău, dacă este cazul) ești de acord să respecți termenii [licenței proiectului](../LICENCE). În git, aceasta este opțiunea `-s` la `git commit`.
- Dacă patch-ul tău nu este revizuit sau ai nevoie ca o anumită persoană să-l revizuiască, poți @-reply unui reviewer cerând o revizuire în pull request sau într-un comentariu, sau poți solicita o revizuire prin [email](mailto:info@rustdesk.com).
- Adaugă teste relevante pentru bug-ul corectat sau pentru funcționalitatea nouă.
Pentru instrucțiuni specifice git, vezi [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
## Conduită
[Codul de Conduită RustDesk](https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md)
## Comunicare
Contributorii RustDesk frecventează [Discord](https://discord.gg/nDceKgxnkV).

View File

@@ -1,15 +1,14 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#freie-öffentliche-server">Server</a> •
<img src="../res/logo-header.svg" alt="RustDesk - Dein Remote-Desktop"><br>
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
<a href="#auf-docker-kompilieren">Docker</a> •
<a href="#dateistruktur">Dateistruktur</a> •
<a href="#screenshots">Screenshots</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
</p>
> [!Vorsicht]
> [!Caution]
> **Haftungsausschluss bei Missbrauch::** <br>
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
@@ -28,11 +27,14 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Get it on Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
## Abhängigkeiten
@@ -64,18 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
```
### Arch (Manjaro)
@@ -114,7 +117,7 @@ cd
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
git clone https://github.com/rustdesk/rustdesk
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
@@ -129,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" .
```
@@ -157,6 +161,7 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
@@ -167,10 +172,11 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
## Screenshots
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)
![Verbindungsmanager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png)
![Verbunden zu einem Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png)
![Dateiübertragung](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP-Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png)

View File

@@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`.
- Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
- Linux/Osx : vcpkg install libvpx libyuv opus aom
- Linux/macOS : vcpkg install libvpx libyuv opus aom
- Exécuter `cargo run`
- Exécutez `cargo run`
## Comment compiler/build sous Linux
@@ -93,7 +93,7 @@ cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
mv libsciter-gtk.so target/debug
Exécution du cargo
cargo run
```
## Comment construire avec Docker

View File

@@ -13,7 +13,9 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Zaawansowane%20Funkcje-blue)](https://rustdesk.com/pricing.html)
Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
## O projekcie
RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
@@ -31,7 +33,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C
## Zależności
Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę sciter.
Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie.
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |

181
docs/README-RO.md Normal file
View File

@@ -0,0 +1,181 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - desktopul tău la distanță"><br>
<a href="../README.md#raw-steps-to-build">Construire</a> •
<a href="../README.md#how-to-build-with-docker">Docker</a> •
<a href="../README.md#file-structure">Structură</a> •
<a href="../README.md#snapshot">Capturi</a><br>
[<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
<b>Avem nevoie de ajutorul tău pentru a traduce acest README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> și <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> în limba ta maternă</b>
</p>
> [!Atenție]
> **Declinare de responsabilitate privind utilizarea abuzivă:** <br>
> Dezvoltatorii RustDesk nu susțin sau aprobă utilizarea neetică sau ilegală a acestui software. Utilizarea abuzivă, cum ar fi accesul neautorizat, controlul sau invadarea intimității, este strict împotriva regulilor noastre. Autorii nu sunt responsabili pentru utilizarea necorespunzătoare a aplicației.
Conversați cu noi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-blue)](https://rustdesk.com/pricing.html)
Încă o soluție de desktop la distanță scrisă în Rust. Funcționează imediat, fără configurare necesară. Ai control total asupra datelor tale, fără probleme de securitate. Poți folosi serverul nostru de rendezvous/relay, [să-ți configurezi propriul server](https://rustdesk.com/server) sau [să scrii propriul server de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
![imagine](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CONTRIBUTING.md) pentru ajutor la început.
[**ÎNTREBĂRI FRECVENTE (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**DESCĂRCARE BINARE**](https://github.com/rustdesk/rustdesk/releases)
[**BUILD NIGHTLY**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Get it on Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
## Dependențe
Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) pentru construire cu Flutter.
Te rugăm să descarci singur librăria dinamică Sciter.
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## Pași pentru construire (Raw Steps to build)
- Pregătește mediul de dezvoltare Rust și mediul de construire C++
- Instalează [vcpkg](https://github.com/microsoft/vcpkg) și setează corect variabila de mediu `VCPKG_ROOT`
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
- Linux/macOS: vcpkg install libvpx libyuv opus aom
- rulează `cargo run`
## [Construire](https://rustdesk.com/docs/en/dev/build/)
## Cum se construiește pe Linux
### Ubuntu 18 (Debian 10)
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
```
### Arch (Manjaro)
```sh
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
```
### Instalează vcpkg
```sh
git clone https://github.com/microsoft/vcpkg
cd vcpkg
git checkout 2023.04.15
cd ..
vcpkg/bootstrap-vcpkg.sh
export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom
```
### Repară libvpx (Pentru Fedora)
```sh
cd vcpkg/buildtrees/libvpx/src
cd *
./configure
sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile
sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile
make
cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
cd
```
### Build
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Cum să construiești cu Docker
Începe prin clonarea repository-ului și construirea imaginii Docker:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" .
```
Apoi, de fiecare dată când trebuie să construiești aplicația, rulează comanda următoare:
```sh
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
```
Reține că prima construire poate dura mai mult până când dependențele sunt în cache; construirile ulterioare vor fi mai rapide. De asemenea, dacă trebuie să specifici argumente diferite comenzii de build, le poți adăuga la finalul comenzii în poziția `<OPTIONAL-ARGS>`. De exemplu, pentru a construi o versiune optimizată de release, adaugă `--release`. Executabilul rezultat va fi disponibil în folderul `target` pe sistemul tău, și poate fi rulat cu:
```sh
target/debug/rustdesk
```
Sau, dacă rulezi un executabil release:
```sh
target/release/rustdesk
```
Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, altfel aplicația poate să nu găsească resursele necesare. De asemenea, reține că alte subcomenzi cargo, cum ar fi `install` sau `run`, nu sunt acceptate în prezent prin această metodă, deoarece ar instala sau rula programul în interiorul containerului în loc de gazdă.
## Structura fișierelor
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec video, config, wrapper tcp/udp, protobuf, funcții fs pentru transfer de fișiere și alte funcții utilitare
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: capturare ecran
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: control tastatură/mouse specific platformei
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementare copy/paste pentru fișiere pentru Windows, Linux, macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interfață Sciter învechită (depreciată)
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: servicii audio/clipboard/input/video și conexiuni de rețea
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inițiază o conexiune peer
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
## Capturi de ecran
![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![Connected to a Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

View File

@@ -167,7 +167,7 @@ target/release/rustdesk
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером Rustdesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter

16
docs/SECURITY-FR.md Normal file
View File

@@ -0,0 +1,16 @@
# Politique de sécurité
## Signaler une vulnérabilité
Nous accordons une très grande importance à la sécurité du projet. Nous
encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils
découvrent.
Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez
la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com.
À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite
équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler
toute vulnérabilité de manière responsable afin que nous puissions continuer à
développer une application sécurisée pour l'ensemble de la communauté.

9
docs/SECURITY-RO.md Normal file
View File

@@ -0,0 +1,9 @@
# Politica de Securitate
## Raportarea unei Vulnerabilități
Acordăm o mare importanță securității proiectului. Încurajăm toți utilizatorii să ne raporteze orice vulnerabilități pe care le descoperă.
Dacă găsești o vulnerabilitate de securitate în proiectul RustDesk, te rugăm să o raportezi responsabil trimițând un e-mail la info@rustdesk.com.
În acest moment, nu avem un program de recompense pentru descoperirea de bug-uri. Suntem o echipă mică care încearcă să rezolve o problemă mare.
Te rugăm să raportezi orice vulnerabilitate în mod responsabil, astfel încât să putem continua să construim o aplicație sigură pentru întreaga comunitate.

View File

@@ -18,7 +18,7 @@
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
<li> P2P connection with end-to-end encryption based on NaCl. </li>
<li> No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand. </li>
<li> No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand. </li>
<li> We like to keep things simple and will strive to make simpler where possible. </li>
</ul>
<p>
@@ -56,4 +56,4 @@
<control>pointing</control>
</supports>
<content_rating type="oars-1.1"/>
</component>
</component>

View File

@@ -55,8 +55,8 @@
],
"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

@@ -62,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return false
}
}
audioRecorder = builder.build()
val recorder = try {
builder.build()
} catch (e: Exception) {
Log.e(logTag, "createAudioRecorder failed", e)
return false
}
audioRecorder = recorder
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}

View File

@@ -311,7 +311,10 @@ class FloatingWindowService : Service(), View.OnTouchListener {
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
}
val idStopService = 2
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
if (!hideStopService) {
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
}
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
idShowRustDesk -> {
@@ -389,4 +392,3 @@ class FloatingWindowService : Service(), View.OnTouchListener {
return false
}
}

View File

@@ -62,7 +62,13 @@ class MainActivity : FlutterActivity() {
channelTag
)
initFlutterChannel(flutterMethodChannel!!)
thread { setCodecInfo() }
thread {
try {
setCodecInfo()
} catch (e: Exception) {
Log.e("MainActivity", "Failed to setCodecInfo: ${e.message}", e)
}
}
}
override fun onResume() {

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()
@@ -23,6 +24,7 @@ object FFI {
external fun setFrameRawEnable(name: String, value: Boolean)
external fun setCodecInfo(info: String)
external fun getLocalOption(key: String): String
external fun getBuildinOption(key: String): String
external fun onClipboardUpdate(clips: ByteBuffer)
external fun isServiceClipboardEnabled(): Boolean
}

View File

@@ -0,0 +1 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="4" width="19" height="19" fill="#f25022"/><rect x="25" y="4" width="19" height="19" fill="#7fba00"/><rect x="4" y="25" width="19" height="19" fill="#00a4ef"/><rect x="25" y="25" width="19" height="19" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 321 B

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

@@ -7,7 +7,7 @@
# 2024, Vasyl Gello <vasek.gello@gmail.com>
#
# The script is invoked by F-Droid builder system ste-by-step.
# The script is invoked by F-Droid builder system step-by-step.
#
# It accepts the following arguments:
#
@@ -16,7 +16,6 @@
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
# - The build step to execute:
#
# + sudo-deps: as root, install needed Debian packages into builder VM
# + prebuild: patch sources and do other stuff before the build
# + build: perform actual build of APK file
#
@@ -184,13 +183,9 @@ prebuild)
fi
# Map NDK version to revision
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2
@@ -316,6 +311,18 @@ prebuild)
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
# Find first libclang.so and set BRIDGE_LLVM_PATH
BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)"
if [ -z "${BRIDGE_LLVM_PATH}" ]; then
echo 'ERROR: Can not find libclang.so for bridge generator!' >&2
exit 1
fi
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
# Install Flutter bridge version
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
@@ -344,7 +351,8 @@ prebuild)
flutter_rust_bridge_codegen \
--rust-input ./src/flutter_ffi.rs \
--dart-output ./flutter/lib/generated_bridge.dart
--dart-output ./flutter/lib/generated_bridge.dart \
--llvm-path "${BRIDGE_LLVM_PATH}"
# Add bridge files to save-list
@@ -355,13 +363,15 @@ prebuild)
git checkout '*'
git clean -dffx
git reset
unset BRIDGE_LLVM_PATH
fi
# Install Flutter version for RustDesk library build
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
# gms is not in thoes files now, but we still keep the following line for future reference(maybe).
# gms is not in these files now, but we still keep the following line for future reference(maybe).
sed \
-i \
@@ -414,13 +424,9 @@ build)
.github/workflows/flutter-build.yml)"
# Map NDK version to revision
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2

View File

@@ -43,6 +43,8 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -60,6 +62,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>

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,
@@ -1121,18 +1124,23 @@ class CustomAlertDialog extends StatelessWidget {
Widget createDialogContent(String text) {
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
bool hasLink = linkRegExp.hasMatch(text);
// Early return: no link, use default theme color
if (!hasLink) {
return SelectableText(text, style: const TextStyle(fontSize: 15));
}
final List<TextSpan> spans = [];
int start = 0;
bool hasLink = false;
linkRegExp.allMatches(text).forEach((match) {
hasLink = true;
if (match.start > start) {
spans.add(TextSpan(text: text.substring(start, match.start)));
}
spans.add(TextSpan(
text: match.group(0) ?? '',
style: TextStyle(
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
@@ -1150,13 +1158,9 @@ Widget createDialogContent(String text) {
spans.add(TextSpan(text: text.substring(start)));
}
if (!hasLink) {
return SelectableText(text, style: const TextStyle(fontSize: 15));
}
return SelectableText.rich(
TextSpan(
style: TextStyle(color: Colors.black, fontSize: 15),
style: const TextStyle(fontSize: 15),
children: spans,
),
);
@@ -1575,7 +1579,7 @@ bool option2bool(String option, String value) {
option == kOptionForceAlwaysRelay) {
res = value == "Y";
} else {
assert(false);
// "" is true
res = value != "N";
}
return res;
@@ -1593,9 +1597,6 @@ String bool2option(String option, bool b) {
option == kOptionForceAlwaysRelay) {
res = b ? 'Y' : defaultOptionNo;
} else {
if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
assert(false);
}
res = b ? 'Y' : 'N';
}
return res;
@@ -1681,13 +1682,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() {
@@ -1736,22 +1736,29 @@ final Debouncer _saveWindowDebounce = Debouncer(delay: Duration(seconds: 1));
/// Save window position and size on exit
/// Note that windowId must be provided if it's subwindow
Future<void> saveWindowPosition(WindowType type, {int? windowId, bool? flush}) async {
Future<void> saveWindowPosition(WindowType type,
{int? windowId, bool? flush}) async {
if (type != WindowType.Main && windowId == null) {
debugPrint(
"Error: windowId cannot be null when saving positions for sub window");
}
late Offset position;
late Size sz;
Offset? position;
Size? sz;
late bool isMaximized;
bool isFullscreen = stateGlobal.fullscreen.isTrue;
setPreFrame() {
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
var lpos = LastWindowPosition.loadFromString(pos);
position = Offset(
lpos?.offsetWidth ?? position.dx, lpos?.offsetHeight ?? position.dy);
sz = Size(lpos?.width ?? sz.width, lpos?.height ?? sz.height);
if (lpos != null) {
if (lpos.offsetWidth != null && lpos.offsetHeight != null) {
position = Offset(lpos.offsetWidth!, lpos.offsetHeight!);
}
if (lpos.width != null && lpos.height != null) {
sz = Size(lpos.width!, lpos.height!);
}
}
}
switch (type) {
@@ -1791,24 +1798,25 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId, bool? flush}) a
}
break;
}
if (isWindows) {
if (isWindows && position != null) {
const kMinOffset = -10000;
const kMaxOffset = 10000;
if (position.dx < kMinOffset ||
position.dy < kMinOffset ||
position.dx > kMaxOffset ||
position.dy > kMaxOffset) {
if (position!.dx < kMinOffset ||
position!.dy < kMinOffset ||
position!.dx > kMaxOffset ||
position!.dy > kMaxOffset) {
debugPrint("Invalid position: $position, ignore saving position");
return;
}
}
final pos = LastWindowPosition(
sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen);
final pos = LastWindowPosition(sz?.width, sz?.height, position?.dx,
position?.dy, isMaximized, isFullscreen);
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) {
@@ -1834,10 +1842,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);
}
}
}
@@ -1924,44 +1933,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);
@@ -2359,6 +2365,19 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
id = uri.path.substring("/new/".length);
} else if (uri.authority == "config") {
if (isAndroid || isIOS) {
final allowDeepLinkServerSettings =
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) ==
'Y';
if (!allowDeepLinkServerSettings) {
debugPrint(
"Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled.");
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
// Delay toast to avoid missing overlay during cold-start deeplink handling.
Timer(Duration(seconds: 1), () {
showToast(translate('Failed'));
});
return null;
}
final config = uri.path.substring("/".length);
// add a timer to make showToast work
Timer(Duration(seconds: 1), () {
@@ -2368,11 +2387,24 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
return null;
} else if (uri.authority == "password") {
if (isAndroid || isIOS) {
final allowDeepLinkPassword =
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y';
if (!allowDeepLinkPassword) {
debugPrint(
"Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled.");
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
// Delay toast to avoid missing overlay during cold-start deeplink handling.
Timer(Duration(seconds: 1), () {
showToast(translate('Failed'));
});
return null;
}
final password = uri.path.substring("/".length);
if (password.isNotEmpty) {
Timer(Duration(seconds: 1), () async {
await bind.mainSetPermanentPassword(password: password);
showToast(translate('Successful'));
final ok =
await bind.mainSetPermanentPasswordWithResult(password: password);
showToast(translate(ok ? 'Successful' : 'Failed'));
});
}
}
@@ -2668,6 +2700,55 @@ 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 = {};
// Don't use WakelockPlus.enabled, it causes error on Android:
// Unhandled Exception: FormatException: Message corrupted
//
// On Linux, multiple enable() calls create only one inhibit, but each disable()
// only releases if _cookie != null. So we need our own _enabled state to avoid
// calling disable() when not enabled.
// See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48
static bool _enabled = false;
static void enable(UniqueKey key, {bool isServer = false}) {
// Check if we should keep awake during outgoing sessions
if (!isServer) {
final keepAwake =
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
if (!keepAwake) {
return; // Don't enable wakelock if user disabled keep awake
}
}
if (isDesktop) {
_enabledKeys.add(key);
}
if (!_enabled) {
_enabled = true;
WakelockPlus.enable();
}
}
static void disable(UniqueKey key) {
if (isDesktop) {
_enabledKeys.remove(key);
if (_enabledKeys.isNotEmpty) {
return;
}
}
if (_enabled) {
WakelockPlus.disable();
_enabled = false;
}
}
}
/// call this to reload current window.
///
/// [Note]
@@ -2941,7 +3022,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,
@@ -3008,10 +3089,26 @@ Future<void> start_service(bool is_start) async {
}
Future<bool> canBeBlocked() async {
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
if (isWeb) {
// Web can only act as a controller, never as a controlled side,
// so it should never be blocked by a remote session.
return false;
}
// 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
@@ -3774,6 +3871,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();
@@ -4017,3 +4124,63 @@ 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 '';
}
}
/// Build an avatar widget from an avatar URL or data URI string.
/// Returns [fallback] if avatar is empty or cannot be decoded.
/// [borderRadius] defaults to [size]/2 (circle).
Widget? buildAvatarWidget({
required String avatar,
required double size,
double? borderRadius,
Widget? fallback,
}) {
final trimmed = avatar.trim();
if (trimmed.isEmpty) return fallback;
ImageProvider? imageProvider;
if (trimmed.startsWith('data:image/')) {
final comma = trimmed.indexOf(',');
if (comma > 0) {
try {
imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1)));
} catch (_) {}
}
} else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
imageProvider = NetworkImage(trimmed);
}
if (imageProvider == null) return fallback;
final radius = borderRadius ?? size / 2;
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image(
image: imageProvider,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
fallback ?? SizedBox.shrink(),
),
);
}

View File

@@ -25,6 +25,8 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
// Is all the fields of the user needed?
class UserPayload {
String name = '';
String displayName = '';
String avatar = '';
String email = '';
String note = '';
String? verifier;
@@ -33,6 +35,8 @@ class UserPayload {
UserPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '',
displayName = json['display_name'] ?? '',
avatar = json['avatar'] ?? '',
email = json['email'] ?? '',
note = json['note'] ?? '',
verifier = json['verifier'],
@@ -46,6 +50,8 @@ class UserPayload {
Map<String, dynamic> toJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
'avatar': avatar,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified
@@ -58,9 +64,14 @@ class UserPayload {
Map<String, dynamic> toGroupCacheJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
};
return map;
}
String get displayNameOrName {
return displayName.trim().isEmpty ? name : displayName;
}
}
class PeerPayload {

View File

@@ -54,9 +54,9 @@ class _AddressBookState extends State<AddressBook> {
const LinearProgressIndicator(),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.currentAbPullError,
err: gFFI.abModel.abPullError,
retry: null,
close: () => gFFI.abModel.currentAbPullError.value = ''),
close: gFFI.abModel.clearPullErrors),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.currentAbPushError,

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) {
@@ -2121,15 +2362,20 @@ void showWindowsSessionsDialog(
return CustomAlertDialog(
title: null,
content: msgboxContent(type, title, text),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
msgboxContent(type, title, text).marginOnly(bottom: 12),
ComboBox(
keys: sids,
values: names,
initialKey: selectedUserValue,
onChanged: (value) {
selectedUserValue = value;
}),
],
),
actions: [
ComboBox(
keys: sids,
values: names,
initialKey: selectedUserValue,
onChanged: (value) {
selectedUserValue = value;
}),
dialogButton('Connect', onPressed: submit, isOutline: false),
],
);

View File

@@ -25,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
GestureDragStartCallback? onOneFingerPanStart;
GestureDragUpdateCallback? onOneFingerPanUpdate;
GestureDragEndCallback? onOneFingerPanEnd;
GestureDragCancelCallback? onOneFingerPanCancel;
// twoFingerScale : scale + pan event
GestureScaleStartCallback? onTwoFingerScaleStart;
@@ -169,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
DragEndDetails(velocity: d.velocity);
@override
void rejectGesture(int pointer) {
super.rejectGesture(pointer);
switch (_currentState) {
case GestureState.oneFingerPan:
if (onOneFingerPanCancel != null) {
onOneFingerPanCancel!();
}
break;
case GestureState.twoFingerScale:
// Reset scale state if needed, currently self-contained
break;
case GestureState.threeFingerVerticalDrag:
// Reset drag state if needed, currently self-contained
break;
default:
break;
}
_currentState = GestureState.none;
}
}
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
@@ -717,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({
GestureDragStartCallback? onOneFingerPanStart,
GestureDragUpdateCallback? onOneFingerPanUpdate,
GestureDragEndCallback? onOneFingerPanEnd,
GestureDragCancelCallback? onOneFingerPanCancel,
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
GestureScaleEndCallback? onTwoFingerScaleEnd,
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
@@ -765,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({
..onOneFingerPanStart = onOneFingerPanStart
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;

View File

@@ -20,7 +20,8 @@ const kOpSvgList = [
'okta',
'facebook',
'azure',
'auth0'
'auth0',
'microsoft'
];
class _IconOP extends StatelessWidget {
@@ -103,7 +104,7 @@ class ButtonOP extends StatelessWidget {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(
child: Text('${translate("Continue with")} $opLabel')),
child: Text(translate("Continue with {$opLabel}"))),
),
),
],
@@ -224,21 +225,59 @@ class _WidgetOPState extends State<WidgetOP> {
return Offstage(
offstage:
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
child: RichText(
text: TextSpan(
text: '$_stateMsg ',
style:
DefaultTextStyle.of(context).style.copyWith(fontSize: 12),
children: <TextSpan>[
TextSpan(
text: _failedMsg,
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: 14,
color: Colors.red,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SelectableText(
translate(_stateMsg),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: 12),
),
),
],
),
if (_failedMsg.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Builder(builder: (context) {
final errorColor =
Theme.of(context).colorScheme.error;
final bgColor = Theme.of(context)
.colorScheme
.errorContainer
.withOpacity(0.3);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 6.0),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
color: errorColor, size: 16),
const SizedBox(width: 6),
Flexible(
child: SelectableText(
translate(_failedMsg),
style: DefaultTextStyle.of(context)
.style
.copyWith(
fontSize: 13,
color: errorColor,
),
),
),
],
),
);
}),
),
],
),
);
}),
@@ -400,6 +439,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 +598,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

@@ -158,12 +158,18 @@ class _MyGroupState extends State<MyGroup> {
return Obx(() {
final userItems = gFFI.groupModel.users.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
.toLowerCase()
.contains(searchAccessibleItemNameText.value.toLowerCase());
final search = searchAccessibleItemNameText.value.toLowerCase();
return p0.name.toLowerCase().contains(search) ||
p0.displayNameOrName.toLowerCase().contains(search);
}
return true;
}).toList();
// Count occurrences of each displayNameOrName to detect duplicates
final displayNameCount = <String, int>{};
for (final u in userItems) {
final dn = u.displayNameOrName;
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
}
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
@@ -177,7 +183,8 @@ class _MyGroupState extends State<MyGroup> {
itemCount: deviceGroupItems.length + userItems.length,
itemBuilder: (context, index) => index < deviceGroupItems.length
? _buildDeviceGroupItem(deviceGroupItems[index])
: _buildUserItem(userItems[index - deviceGroupItems.length]));
: _buildUserItem(userItems[index - deviceGroupItems.length],
displayNameCount));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse
? listView(false)
@@ -185,8 +192,14 @@ class _MyGroupState extends State<MyGroup> {
});
}
Widget _buildUserItem(UserPayload user) {
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
final username = user.name;
final dn = user.displayNameOrName;
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
final displayName =
isDuplicate && user.displayName.trim().isNotEmpty
? '${user.displayName} (@$username)'
: dn;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = false;
if (selectedAccessibleItemName.value != username) {
@@ -222,14 +235,14 @@ class _MyGroupState extends State<MyGroup> {
alignment: Alignment.center,
child: Center(
child: Text(
username.characters.first.toUpperCase(),
displayName.characters.first.toUpperCase(),
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
).marginOnly(right: 4),
if (isMe) Flexible(child: Text(username)),
if (isMe) Flexible(child: Text(displayName)),
if (isMe)
Flexible(
child: Container(
@@ -246,7 +259,7 @@ class _MyGroupState extends State<MyGroup> {
),
),
),
if (!isMe) Expanded(child: Text(username)),
if (!isMe) Expanded(child: Text(displayName)),
],
).paddingSymmetric(vertical: 4),
),

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

@@ -570,11 +570,14 @@ class MyGroupPeerView extends BasePeersView {
static bool filter(Peer peer) {
final model = gFFI.groupModel;
if (model.searchAccessibleItemNameText.isNotEmpty) {
final text = model.searchAccessibleItemNameText.value;
final searchPeersOfUser = peer.loginName.contains(text) &&
model.users.any((user) => user.name == peer.loginName);
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
final text = model.searchAccessibleItemNameText.value.toLowerCase();
final searchPeersOfUser = model.users.any((user) =>
user.name == peer.loginName &&
(user.name.toLowerCase().contains(text) ||
user.displayNameOrName.toLowerCase().contains(text)));
final searchPeersOfDeviceGroup =
peer.device_group_name.toLowerCase().contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false;
}

View File

@@ -31,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget {
// https://github.com/flutter/flutter/issues/154053
final useRawKeyEvents = isLinux && !isWeb;
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
// while `Alt` and `Control` are seperated key events for en-US input method.
// while `Alt` and `Control` are separated key events for en-US input method.
return FocusScope(
autofocus: true,
child: Focus(
@@ -107,6 +107,8 @@ class _RawTouchGestureDetectorRegionState
// For mouse mode, we need to block the events when the cursor is in a blocked area.
// So we need to cache the last tap down position.
Offset? _lastTapDownPositionForMouseMode;
// Cache global position for onTap (which lacks position info).
Offset? _lastTapDownGlobalPosition;
FFI get ffi => widget.ffi;
FfiModel get ffiModel => widget.ffiModel;
@@ -136,6 +138,7 @@ class _RawTouchGestureDetectorRegionState
onTapDown(TapDownDetails d) async {
lastDeviceKind = d.kind;
_lastTapDownGlobalPosition = d.globalPosition;
if (isNotTouchBasedDevice()) {
return;
}
@@ -154,11 +157,16 @@ class _RawTouchGestureDetectorRegionState
if (isNotTouchBasedDevice()) {
return;
}
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
return;
}
if (handleTouch) {
final isMoved =
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
if (isMoved) {
if (lastTapDownDetails != null) {
// If pan already handled 'down', don't send it again.
if (lastTapDownDetails != null && !_touchModePanStarted) {
await inputModel.tapDown(MouseButtons.left);
}
await inputModel.tapUp(MouseButtons.left);
@@ -170,6 +178,11 @@ class _RawTouchGestureDetectorRegionState
if (isNotTouchBasedDevice()) {
return;
}
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
final lastPos = _lastTapDownGlobalPosition;
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
return;
}
if (!handleTouch) {
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
// Using `_lastTapDownPositionForMouseMode` instead.
@@ -372,7 +385,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 +413,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,10 +430,21 @@ 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);
}
}
}
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
// or rejected by the gesture arena. Without this, the flag can remain
// stuck in the "started" state and cause issues such as the Magic Mouse
// double-click problem on iPad with magic mouse.
onOneFingerPanCancel() {
_touchModePanStarted = false;
}
// scale + pan event
onTwoFingerScaleStart(ScaleStartDetails d) {
_lastTapDownDetails = null;
@@ -500,7 +532,9 @@ class _RawTouchGestureDetectorRegionState
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(), (instance) {
() => TapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
@@ -508,14 +542,18 @@ class _RawTouchGestureDetectorRegionState
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(), (instance) {
() => DoubleTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(), (instance) {
() => LongPressGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp
@@ -525,7 +563,9 @@ class _RawTouchGestureDetectorRegionState
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(),
() => HoldTapMoveGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
@@ -533,19 +573,24 @@ class _RawTouchGestureDetectorRegionState
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(), (instance) {
() => DoubleFinerTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleFinerTap = onDoubleFinerTap
..onDoubleFinerTapDown = onDoubleFinerTapDown;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(), (instance) {
() => CustomTouchGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance.onOneFingerPanStart =
(DragStartDetails d) => onOneFingerPanStart(context, d);
instance
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleStart = onTwoFingerScaleStart
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd

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
@@ -261,7 +275,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
isDesktop &&
ffiModel.keyboard &&
pi.platform != kPeerPlatformAndroid &&
pi.platform != kPeerPlatformMacOS &&
versionCmp(pi.version, '1.2.0') >= 0 &&
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
v.add(TTextMenu(
@@ -817,6 +830,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
@@ -838,6 +852,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,21 +162,34 @@ 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";
const String kOptionHideStopService = "hide-stop-service";
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
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";
const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
const String kOptionAllowDeepLinkServerSettings =
"allow-deep-link-server-settings";
const String kOptionToggleViewOnly = "view-only";
const String kOptionToggleShowMyCursor = "show-my-cursor";
@@ -181,6 +198,9 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
const String kOptionKeepScreenOn = "keep-screen-on";
const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
const String kOptionShowMobileAction = "showMobileActions";
const String kUrlActionClose = "close";
@@ -245,6 +265,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 +366,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 +402,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';
@@ -449,7 +450,11 @@ class _DesktopHomePageState extends State<DesktopHomePage>
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
btnText,
onPressed,
closeButton: true);
closeButton: true,
help: isToUpdate ? 'Changelog' : null,
link: isToUpdate
? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
: null);
}
if (systemError.isNotEmpty) {
return buildInstallCard("", systemError, "", () {});
@@ -760,11 +765,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 +810,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;
@@ -887,12 +908,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
final pw = await bind.mainGetPermanentPassword();
final p0 = TextEditingController(text: pw);
final p1 = TextEditingController(text: pw);
final p0 = TextEditingController(text: "");
final p1 = TextEditingController(text: "");
var errMsg0 = "";
var errMsg1 = "";
final RxString rxPass = pw.trim().obs;
final localPasswordSet =
(await bind.mainGetCommon(key: "local-permanent-password-set")) == "true";
final permanentPasswordSet =
(await bind.mainGetCommon(key: "permanent-password-set")) == "true";
final presetPassword = permanentPasswordSet && !localPasswordSet;
var canSubmit = false;
final RxString rxPass = "".obs;
final rules = [
DigitValidationRule(),
UppercaseValidationRule(),
@@ -901,9 +927,21 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
MinCharactersValidationRule(8),
];
final maxLength = bind.mainMaxEncryptLen();
final statusTip = localPasswordSet
? translate('password-hidden-tip')
: (presetPassword ? translate('preset-password-in-use-tip') : '');
final showStatusTipOnMobile =
statusTip.isNotEmpty && !isDesktop && !isWebDesktop;
gFFI.dialogManager.show((setState, close, context) {
submit() {
updateCanSubmit() {
canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty;
}
submit() async {
if (!canSubmit) {
return;
}
setState(() {
errMsg0 = "";
errMsg1 = "";
@@ -926,7 +964,13 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
});
return;
}
bind.mainSetPermanentPassword(password: pass);
final ok = await bind.mainSetPermanentPasswordWithResult(password: pass);
if (!ok) {
setState(() {
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
});
return;
}
if (pass.isNotEmpty) {
notEmptyCallback?.call();
}
@@ -934,14 +978,20 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
}
return CustomAlertDialog(
title: Text(translate("Set Password")),
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.key, color: MyTheme.accent),
Text(translate("Set Password")).paddingOnly(left: 10),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 8.0,
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 6.0,
),
Row(
children: [
@@ -957,6 +1007,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
rxPass.value = value.trim();
setState(() {
errMsg0 = '';
updateCanSubmit();
});
},
maxLength: maxLength,
@@ -968,9 +1019,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
children: [
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
],
).marginSymmetric(vertical: 8),
const SizedBox(
height: 8.0,
).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8),
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 8.0,
),
Row(
children: [
@@ -984,6 +1035,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
onChanged: (value) {
setState(() {
errMsg1 = '';
updateCanSubmit();
});
},
maxLength: maxLength,
@@ -991,11 +1043,23 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
),
],
),
const SizedBox(
height: 8.0,
if (statusTip.isNotEmpty)
Row(
children: [
Icon(Icons.info, color: Colors.amber, size: 18)
.marginOnly(right: 6),
Expanded(
child: Text(
statusTip,
style: const TextStyle(fontSize: 13, height: 1.1),
))
],
).marginOnly(top: 6, bottom: 2),
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 8.0,
),
Obx(() => Wrap(
runSpacing: 8,
runSpacing: showStatusTipOnMobile ? 2.0 : 8.0,
spacing: 4,
children: rules.map((e) {
var checked = e.validate(rxPass.value.trim());
@@ -1015,11 +1079,67 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
],
),
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
actions: (() {
final cancelButton = dialogButton(
"Cancel",
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
);
final removeButton = dialogButton(
"Remove",
icon: Icon(Icons.delete_outline_rounded),
onPressed: () async {
setState(() {
errMsg0 = "";
errMsg1 = "";
});
final ok =
await bind.mainSetPermanentPasswordWithResult(password: "");
if (!ok) {
setState(() {
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
});
return;
}
close();
},
buttonStyle: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Colors.red)),
);
final okButton = dialogButton(
"OK",
icon: Icon(Icons.done_rounded),
onPressed: canSubmit ? submit : null,
);
if (!isDesktop && !isWebDesktop && localPasswordSet) {
return [
Align(
alignment: Alignment.centerRight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
cancelButton,
const SizedBox(width: 4),
removeButton,
const SizedBox(width: 4),
okButton,
],
),
),
),
];
}
return [
cancelButton,
if (localPasswordSet) removeButton,
okButton,
];
})(),
onSubmit: canSubmit ? submit : null,
onCancel: close,
);
});

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';
@@ -457,23 +458,31 @@ class _GeneralState extends State<_General> {
return const Offstage();
}
return _Card(title: 'Service', children: [
Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
() async {
serviceBtnEnabled.value = false;
await start_service(serviceStop.value);
// enable the button after 1 second
Future.delayed(const Duration(seconds: 1), () {
serviceBtnEnabled.value = true;
});
}();
}, enabled: serviceBtnEnabled.value))
]);
final hideStopService =
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
return Obx(() {
if (hideStopService && !serviceStop.value) {
return const Offstage();
}
return _Card(title: 'Service', children: [
_Button(serviceStop.value ? 'Start' : 'Stop', () {
() async {
serviceBtnEnabled.value = false;
await start_service(serviceStop.value);
// enable the button after 1 second
Future.delayed(const Duration(seconds: 1), () {
serviceBtnEnabled.value = true;
});
}();
}, enabled: serviceBtnEnabled.value)
]);
});
}
Widget other() {
final showAutoUpdate =
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
final showAutoUpdate = isWindows && bind.mainIsInstalled();
final children = <Widget>[
if (!isWeb && !bind.isIncomingOnly())
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
@@ -556,10 +565,36 @@ class _GeneralState extends State<_General> {
],
],
];
// Add client-side wakelock option for desktop platforms
if (!bind.isIncomingOnly()) {
children.add(_OptionCheckBox(
context,
'keep-awake-during-outgoing-sessions-label',
kOptionKeepAwakeDuringOutgoingSessions,
isServer: false,
));
}
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
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 +844,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),
]),
),
@@ -1073,8 +1109,13 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetPermanentPassword())
.isEmpty) {
(await bind.mainGetCommon(
key: "permanent-password-set")) !=
"true") {
if (isChangePermanentPasswordDisabled()) {
await callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
@@ -1177,9 +1218,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)
@@ -1198,11 +1239,14 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
...directIp(context),
whitelist(),
...autoDisconnect(context),
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
kOptionKeepAwakeDuringIncomingSessions,
reverse: false, enabled: enabled),
if (bind.mainIsInstalled())
_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 +1629,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 +1661,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 +1782,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 +1800,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 +1858,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(
@@ -1904,7 +2025,9 @@ class _AccountState extends State<_Account> {
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
@@ -1913,24 +2036,65 @@ class _AccountState extends State<_Account> {
}
Widget useInfo() {
text(String key, String value) {
return Align(
alignment: Alignment.centerLeft,
child: SelectionArea(child: Text('${translate(key)}: $value'))
.marginSymmetric(vertical: 4),
);
}
return Obx(() => Offstage(
offstage: gFFI.userModel.userName.value.isEmpty,
child: Column(
children: [
text('Username', gFFI.userModel.userName.value),
// text('Group', gFFI.groupModel.groupName.value),
],
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Builder(builder: (context) {
final avatarWidget = _buildUserAvatar();
return Row(
children: [
if (avatarWidget != null) avatarWidget,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
gFFI.userModel.displayNameOrUserName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
SelectionArea(
child: Text(
'@${gFFI.userModel.userName.value}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color:
Theme.of(context).textTheme.bodySmall?.color,
),
),
),
],
),
),
],
);
}),
),
)).marginOnly(left: 18, top: 16);
}
Widget? _buildUserAvatar() {
// Resolve relative avatar path at display time
final avatar =
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 44,
);
}
}
class _Checkbox extends StatefulWidget {
@@ -2018,7 +2182,9 @@ class _PluginState extends State<_Plugin> {
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
@@ -2426,6 +2592,49 @@ class WaylandCard extends StatefulWidget {
class _WaylandCardState extends State<WaylandCard> {
final restoreTokenKey = 'wayland-restore-token';
static const _kClearShortcutsInhibitorEventKey =
'clear-gnome-shortcuts-inhibitor-permission-res';
final _clearShortcutsInhibitorFailedMsg = ''.obs;
// Don't show the shortcuts permission reset button for now.
// Users can change it manually:
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
// For resetting(clearing) the permission from the portal permission store, you can
// use (replace <desktop-id> with the RustDesk desktop file ID):
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
//
// We may add it back in the future if needed.
final showResetInhibitorPermission = false;
@override
void initState() {
super.initState();
if (showResetInhibitorPermission) {
platformFFI.registerEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
(evt) async {
if (!mounted) return;
if (evt['success'] == true) {
setState(() {});
} else {
_clearShortcutsInhibitorFailedMsg.value =
evt['msg'] as String? ?? 'Unknown error';
}
});
}
}
@override
void dispose() {
if (showResetInhibitorPermission) {
platformFFI.unregisterEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -2433,9 +2642,16 @@ class _WaylandCardState extends State<WaylandCard> {
future: bind.mainHandleWaylandScreencastRestoreToken(
key: restoreTokenKey, value: "get"),
hasData: (restoreToken) {
final hasShortcutsPermission = showResetInhibitorPermission &&
bind.mainGetCommonSync(
key: "has-gnome-shortcuts-inhibitor-permission") ==
"true";
final children = [
if (restoreToken.isNotEmpty)
_buildClearScreenSelection(context, restoreToken),
if (hasShortcutsPermission)
_buildClearShortcutsInhibitorPermission(context),
];
return Offstage(
offstage: children.isEmpty,
@@ -2480,6 +2696,50 @@ class _WaylandCardState extends State<WaylandCard> {
),
);
}
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
onConfirm() {
_clearShortcutsInhibitorFailedMsg.value = '';
bind.mainSetCommon(
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
gFFI.dialogManager.dismissAll();
}
showConfirmMsgBox() => msgBoxCommon(
gFFI.dialogManager,
'Confirmation',
Text(
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
),
[
dialogButton('OK', onPressed: onConfirm),
dialogButton('Cancel',
onPressed: () => gFFI.dialogManager.dismissAll())
]);
return Column(children: [
Obx(
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
? Offstage()
: Align(
alignment: Alignment.topLeft,
child: Text(_clearShortcutsInhibitorFailedMsg.value,
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.red))
.marginOnly(bottom: 10.0)),
),
_Button(
'Reset keyboard shortcuts permission',
showConfirmMsgBox,
tip: 'clear-shortcuts-inhibitor-permission-tip',
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
Theme.of(context).colorScheme.error.withOpacity(0.75)),
),
),
]);
}
}
// ignore: non_constant_identifier_names
@@ -2561,7 +2821,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

@@ -462,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 70,
height: 70,
alignment: Alignment.center,
decoration: BoxDecoration(
color: str2color(client.name),
borderRadius: BorderRadius.circular(15.0),
),
child: Text(
client.name[0],
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 55,
),
),
).marginOnly(right: 10.0),
_buildClientAvatar().marginOnly(right: 10.0),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -582,6 +566,36 @@ class _CmHeaderState extends State<_CmHeader>
@override
bool get wantKeepAlive => true;
Widget _buildClientAvatar() {
return buildAvatarWidget(
avatar: client.avatar,
size: 70,
borderRadius: 15,
fallback: _buildInitialAvatar(),
) ??
_buildInitialAvatar();
}
Widget _buildInitialAvatar() {
return Container(
width: 70,
height: 70,
alignment: Alignment.center,
decoration: BoxDecoration(
color: str2color(client.name),
borderRadius: BorderRadius.circular(15.0),
),
child: Text(
client.name.isNotEmpty ? client.name[0] : '?',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 55,
),
),
);
}
}
class _PrivilegeBoard extends StatefulWidget {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
@@ -8,13 +9,14 @@ 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,
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
required this.tabKey,
this.forceRelay,
this.connToken,
}) : super(key: key);
@@ -25,20 +27,35 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
final String tabKey;
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;
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
StreamSubscription<DesktopTabState>? _tabStateSubscription;
@override
void initState() {
super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
@@ -53,18 +70,37 @@ class _TerminalPageState extends State<TerminalPage>
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
// Enable focus once terminal has valid dimensions (first valid resize)
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
_terminalFocusNode.canRequestFocus = true;
// Auto-focus if this tab is currently selected
_requestFocusIfSelected();
}
// 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
@@ -79,38 +115,86 @@ class _TerminalPageState extends State<TerminalPage>
@override
void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
void _onTabStateChanged(DesktopTabState state) {
// Check if this tab is now selected and request focus
if (state.selected >= 0 && state.selected < state.tabs.length) {
final selectedTab = state.tabs[state.selected];
if (selectedTab.key == widget.tabKey && mounted) {
_requestFocusIfSelected();
}
}
}
void _requestFocusIfSelected() {
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
// Use post-frame callback to ensure widget is fully laid out in focus tree
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
final state = widget.tabController.state.value;
if (state.selected >= 0 && state.selected < state.tabs.length) {
if (state.tabs[state.selected].key == widget.tabKey) {
_terminalFocusNode.requestFocus();
}
}
});
}
// 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,
focusNode: _terminalFocusNode,
// Note: autofocus is not used here because focus is managed manually
// via _onTabStateChanged() to handle tab switching properly.
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';
@@ -33,6 +34,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
// Lightweight idempotency guard for async close operations
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -61,27 +66,20 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
String? connToken,
}) {
final tabKey = '${peerId}_$terminalId';
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
return TabInfo(
key: tabKey,
label: '$peerId #$terminalId',
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
// Close the terminal session first
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi != null) {
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
await terminalModel.closeTerminal();
}
}
// Then close the tab
tabController.closeBy(tabKey);
},
onTabCloseButton: () => _closeTab(tabKey),
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
@@ -91,6 +89,159 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
);
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
// Idempotency guard: skip if already closing this tab
if (_closingTabs.contains(tabKey)) return;
_closingTabs.add(tabKey);
try {
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
// _closeAllTabs clearing tabController (which would make the live count
// drop to 0 and incorrectly trigger session persistence).
// Note: the snapshot may become stale if other individual tabs are closed
// during the audit dialog, but this is an acceptable trade-off.
int? snapshotPeerTabCount;
final parsed = _parseTabKey(tabKey);
if (parsed != null) {
final (peerId, _) = parsed;
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
}
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close terminal session if not in persistent mode.
// Wrapped separately so session cleanup failure never blocks UI tab removal.
try {
await _closeTerminalSessionIfNeeded(tabKey,
peerTabCount: snapshotPeerTabCount);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
// Always close the tab from UI, regardless of session cleanup result
tabController.closeBy(tabKey);
} catch (e) {
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
} finally {
_closingTabs.remove(tabKey);
}
}
/// Close all tabs with session cleanup.
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
/// UI tabs are removed immediately; session cleanup runs in parallel with a
/// bounded timeout so window close is not blocked indefinitely.
Future<void> _closeAllTabs() async {
_windowClosing = true;
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
final futures = tabKeys
.where((tabKey) => !_closingTabs.contains(tabKey))
.map((tabKey) async {
try {
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
}).toList();
if (futures.isNotEmpty) {
await Future.wait(futures).timeout(
const Duration(seconds: 4),
onTimeout: () {
debugPrint(
'[TerminalTabPage] Session cleanup timed out for batch close');
return [];
},
);
}
}
/// Close the terminal session on server side based on persistent mode.
///
/// [persistAll] controls behavior when persistent mode is enabled:
/// - `true` (window close): persist all sessions, don't close any.
/// - `false` (tab close): only persist the last session for the peer,
/// close others so only the most recent disconnected session survives.
///
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
/// in-flight _closeTab() calls don't accidentally close sessions that the
/// window-close flow intends to preserve.
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
{bool persistAll = false, int? peerTabCount}) async {
// If window close is in progress, override to persist all sessions
// even if this call originated from an individual tab close.
if (_windowClosing) {
persistAll = true;
}
final parsed = _parseTabKey(tabKey);
if (parsed == null) return;
final (peerId, terminalId) = parsed;
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi == null) return;
final isPersistent = bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
if (isPersistent) {
if (persistAll) {
// Window close: persist all sessions
return;
}
// Tab close: only persist if this is the last tab for this peer.
// Use the snapshot value if provided (avoids race with concurrent tab removal).
final effectivePeerTabCount = peerTabCount ??
tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
if (effectivePeerTabCount <= 1) {
// Last tab for this peer — persist the session
return;
}
// Not the last tab — fall through to close the session
}
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
// closeTerminal() has internal 3s timeout, no need for external timeout
await terminalModel.closeTerminal();
}
}
/// Parse tabKey (format: "peerId_terminalId") into its components.
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
/// Returns null if tabKey format is invalid.
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore <= 0) {
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
return null;
}
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
final terminalId = int.tryParse(terminalIdStr);
if (terminalId == null) {
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
return null;
}
final peerId = tabKey.substring(0, lastUnderscore);
return (peerId, terminalId);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
@@ -174,7 +325,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
} else if (call.method == kWindowEventRestoreTerminalSessions) {
_restoreSessions(call.arguments);
} else if (call.method == "onDestroy") {
tabController.clear();
// Clean up sessions before window destruction (bounded wait)
await _closeAllTabs();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
@@ -184,7 +336,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final currentTab = tabController.state.value.selectedTabInfo;
assert(call.arguments is String,
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
if (currentTab.key.startsWith(call.arguments)) {
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = currentTab.key.lastIndexOf('_');
if (lastUnderscore > 0 &&
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
windowOnTop(windowId());
return true;
}
@@ -255,7 +410,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
_closeTab(currentTab.key);
return true;
}
} else if (!isMacOS &&
@@ -264,7 +419,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
_closeTab(currentTab.key);
return true;
}
}
@@ -319,7 +474,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) => tab.key.startsWith('$peerId\_'),
(tab) {
final last = tab.key.lastIndexOf('_');
return last > 0 && tab.key.substring(0, last) == peerId;
},
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
@@ -340,11 +498,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
final parts = currentTab.key.split('_');
if (parts.isNotEmpty) {
final peerId = parts[0];
_addNewTerminal(peerId, terminalId: terminalId);
}
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
final (peerId, _) = parsed;
_addNewTerminal(peerId, terminalId: terminalId);
}
@override
@@ -358,10 +515,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
// Extract peerId from tab key (format: "peerId_terminalId")
final parts = key.split('_');
if (parts.isEmpty) return Container();
final peerId = parts[0];
final parsed = _parseTabKey(key);
if (parsed == null) return Container();
final (peerId, _) = parsed;
return _tabMenuBuilder(peerId, () {});
},
));
@@ -407,8 +563,16 @@ 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();
await _closeAllTabs();
return true;
} else {
final bool res;
@@ -419,7 +583,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
await _closeAllTabs();
}
return res;
}

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 {
@@ -153,135 +178,6 @@ class _ToolbarTheme {
typedef DismissFunc = void Function();
class RemoteMenuEntry {
static MenuEntryRadios<String> viewStyle(
String remoteId,
FFI ffi,
EdgeInsets padding, {
DismissFunc? dismissFunc,
DismissCallback? dismissCallback,
RxString? rxViewStyle,
}) {
return MenuEntryRadios<String>(
text: translate('Ratio'),
optionsGetter: () => [
MenuEntryRadioOption(
text: translate('Scale original'),
value: kRemoteViewStyleOriginal,
dismissOnClicked: true,
dismissCallback: dismissCallback,
),
MenuEntryRadioOption(
text: translate('Scale adaptive'),
value: kRemoteViewStyleAdaptive,
dismissOnClicked: true,
dismissCallback: dismissCallback,
),
MenuEntryRadioOption(
text: translate('Scale custom'),
value: kRemoteViewStyleCustom,
dismissOnClicked: true,
dismissCallback: dismissCallback,
),
],
curOptionGetter: () async {
// null means peer id is not found, which there's no need to care about
final viewStyle =
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
if (rxViewStyle != null) {
rxViewStyle.value = viewStyle;
}
return viewStyle;
},
optionSetter: (String oldValue, String newValue) async {
await bind.sessionSetViewStyle(
sessionId: ffi.sessionId, value: newValue);
if (rxViewStyle != null) {
rxViewStyle.value = newValue;
}
ffi.canvasModel.updateViewStyle();
if (dismissFunc != null) {
dismissFunc();
}
},
padding: padding,
dismissOnClicked: true,
dismissCallback: dismissCallback,
);
}
static MenuEntrySwitch2<String> showRemoteCursor(
String remoteId,
SessionID sessionId,
EdgeInsets padding, {
DismissFunc? dismissFunc,
DismissCallback? dismissCallback,
}) {
final state = ShowRemoteCursorState.find(remoteId);
final optKey = 'show-remote-cursor';
return MenuEntrySwitch2<String>(
switchType: SwitchType.scheckbox,
text: translate('Show remote cursor'),
getter: () {
return state;
},
setter: (bool v) async {
await bind.sessionToggleOption(sessionId: sessionId, value: optKey);
state.value =
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: optKey);
if (dismissFunc != null) {
dismissFunc();
}
},
padding: padding,
dismissOnClicked: true,
dismissCallback: dismissCallback,
);
}
static MenuEntrySwitch<String> disableClipboard(
SessionID sessionId,
EdgeInsets? padding, {
DismissFunc? dismissFunc,
DismissCallback? dismissCallback,
}) {
return createSwitchMenuEntry(
sessionId,
'Disable clipboard',
'disable-clipboard',
padding,
true,
dismissCallback: dismissCallback,
);
}
static MenuEntrySwitch<String> createSwitchMenuEntry(
SessionID sessionId,
String text,
String option,
EdgeInsets? padding,
bool dismissOnClicked, {
DismissFunc? dismissFunc,
DismissCallback? dismissCallback,
}) {
return MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate(text),
getter: () async {
return bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: option);
},
setter: (bool v) async {
await bind.sessionToggleOption(sessionId: sessionId, value: option);
if (dismissFunc != null) {
dismissFunc();
}
},
padding: padding,
dismissOnClicked: dismissOnClicked,
dismissCallback: dismissCallback,
);
}
static MenuEntryButton<String> insertLock(
SessionID sessionId,
EdgeInsets? padding, {
@@ -365,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;
@@ -386,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>(
@@ -405,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;
}
}
@@ -419,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(
@@ -526,7 +435,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
),
),
),
_buildDraggableShowHide(context),
_buildDraggableCollapse(context),
],
);
}
@@ -639,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) {
@@ -850,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 {
@@ -1061,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)
@@ -1141,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,
@@ -1167,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),
]);
});
}
@@ -1182,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) ?? '';
@@ -1195,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: [
@@ -1213,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>(
@@ -1222,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(),
]));
});
@@ -1312,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) {
@@ -1446,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,
@@ -1454,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,
),
),
);
@@ -1497,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),
@@ -1508,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),
),
),
]),
@@ -1526,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;
@@ -1537,6 +1369,7 @@ class _RectValueThumbShape extends SliderComponentShape {
required this.height,
required this.radius,
this.displayValueForNormalized,
this.unit = '%',
});
@override
@@ -1577,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,
@@ -1595,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));
}
}
@@ -1931,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(),
@@ -2017,8 +1861,18 @@ class _KeyboardMenu extends StatelessWidget {
continue;
}
if (pi.isWayland && mode.key != kKeyMapMode) {
continue;
if (pi.isWayland) {
// Legacy mode is hidden on desktop control side because dead keys
// don't work properly on Wayland. When the control side is mobile,
// Legacy mode is used automatically (mobile always sends Legacy events).
if (mode.key == kKeyLegacyMode) {
continue;
}
// Translate mode requires server >= 1.4.6.
if (mode.key == kKeyTranslateMode &&
versionCmp(pi.version, '1.4.6') < 0) {
continue;
}
}
var text = translate(mode.menu);
@@ -2206,7 +2060,7 @@ class _ChatMenuState extends State<_ChatMenu> {
ffi: widget.ffi,
color: _ToolbarTheme.blueColor,
hoverColor: _ToolbarTheme.hoverBlueColor,
menuChildrenGetter: () => [textChat(), voiceCall()]);
menuChildrenGetter: (_) => [textChat(), voiceCall()]);
}
}
@@ -2262,7 +2116,7 @@ class _VoiceCallMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
menuChildrenGetter() {
menuChildrenGetter(_IconSubmenuButtonState state) {
final audioInput = AudioInput(
builder: (devices, currentDevice, setDevice) {
return Column(
@@ -2368,7 +2222,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,
);
@@ -2462,7 +2321,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;
@@ -2487,6 +2346,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);
@@ -2519,7 +2383,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
),
child: icon))),
menuChildren: widget
.menuChildrenGetter()
.menuChildrenGetter(this)
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
.toList()));
return MenuBar(children: [
@@ -2684,7 +2548,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() {
@@ -2807,20 +2671,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(
@@ -2882,3 +2746,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

@@ -405,10 +405,15 @@ class _DesktopTabState extends State<DesktopTab>
}
_saveFrame({bool? flush}) async {
if (tabType == DesktopTabType.main) {
await saveWindowPosition(WindowType.Main, flush: flush);
} else if (kWindowType != null && kWindowId != null) {
await saveWindowPosition(kWindowType!, windowId: kWindowId, flush: flush);
try {
if (tabType == DesktopTabType.main) {
await saveWindowPosition(WindowType.Main, flush: flush);
} else if (kWindowType != null && kWindowId != null) {
await saveWindowPosition(kWindowType!,
windowId: kWindowId, flush: flush);
}
} catch (e) {
debugPrint('Error saving window position: $e');
}
}
@@ -588,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
Widget _buildBar() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: GestureDetector(
@@ -1080,11 +1084,12 @@ class _TabState extends State<_Tab> with RestorationMixin {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200),
child: Tooltip(
message: widget.tabType == DesktopTabType.main
? ''
: translate(widget.label.value),
message:
widget.tabType == DesktopTabType.main ? '' : widget.label.value,
child: Text(
translate(widget.label.value),
widget.tabType == DesktopTabType.main
? translate(widget.label.value)
: widget.label.value,
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected

View File

@@ -5,14 +5,17 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
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;
@@ -68,6 +71,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
showLocal ? model.localController : model.remoteController;
FileDirectory get currentDir => currentFileController.directory.value;
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
@override
void initState() {
@@ -82,7 +86,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
WakelockPlus.enable();
WakelockManager.enable(_uniqueKey);
}
@override
@@ -90,8 +94,9 @@ class _FileManagerPageState extends State<FileManagerPage> {
model.close().whenComplete(() {
gFFI.close();
gFFI.dialogManager.dismissAll();
WakelockPlus.disable();
WakelockManager.disable(_uniqueKey);
});
model.jobController.clear();
super.dispose();
}
@@ -112,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(
@@ -351,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();
},
);
@@ -367,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:
@@ -424,6 +434,7 @@ class FileManagerView extends StatefulWidget {
class _FileManagerViewState extends State<FileManagerView> {
final _listScrollController = ScrollController();
final _breadCrumbScroller = ScrollController();
late final ascending = Rx<bool>(controller.sortAscending);
bool get isLocal => widget.controller.isLocal;
FileController get controller => widget.controller;
@@ -635,7 +646,17 @@ class _FileManagerViewState extends State<FileManagerView> {
))
.toList();
},
onSelected: controller.changeSortStyle),
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);
}),
],
)
],

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -14,7 +13,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
@@ -25,6 +23,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;
@@ -65,9 +64,8 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
bool _showGestureHelp = false;
String _value = '';
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
Timer? _timerDidChangeMetrics;
final _uniqueKey = UniqueKey();
Timer? _iosKeyboardWorkaroundTimer;
final _blockableOverlayState = BlockableOverlayState();
@@ -104,9 +102,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
if (!isWeb) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
@@ -141,13 +137,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
if (!isWeb) {
await WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
await keyboardSubscription.cancel();
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
@@ -169,26 +163,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard");
}
@override
void didChangeMetrics() {
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
// Don't try reset the view style and focus the cursor.
if (gFFI.cursorModel.lastKeyboardIsVisible &&
gFFI.canvasModel.isMobileCanvasChanged) {
return;
}
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
_timerDidChangeMetrics?.cancel();
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
if (newBottom != _viewInsetsBottom) {
gFFI.canvasModel.mobileFocusCanvasCursor();
_viewInsetsBottom = newBottom;
}
});
}
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
@@ -210,7 +184,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false);
}
// Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden
// https://github.com/flutter/flutter/issues/39900
// https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue
if (isIOS) {
_iosKeyboardWorkaroundTimer?.cancel();
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () {
if (!mounted) return;
_physicalFocusNode.unfocus();
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () {
if (!mounted) return;
_physicalFocusNode.requestFocus();
});
});
}
} else {
_iosKeyboardWorkaroundTimer?.cancel();
_iosKeyboardWorkaroundTimer = null;
_timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
@@ -365,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
@@ -435,12 +426,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
return Container(
color: MyTheme.canvasColor,
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
);
}),
),
@@ -483,7 +472,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 +557,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 +567,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 +625,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 +798,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
},
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
inputModel: gFFI.inputModel,
)));
}
@@ -1054,11 +1046,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 +1130,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 +1151,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 +1209,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")),
@@ -148,7 +150,12 @@ class _DropDownAction extends StatelessWidget {
}
if (value == kUsePermanentPassword &&
(await bind.mainGetPermanentPassword()).isEmpty) {
(await bind.mainGetCommon(key: "permanent-password-set")) !=
"true") {
if (isChangePermanentPasswordDisabled()) {
callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
@@ -576,10 +583,13 @@ class _PermissionCheckerState extends State<PermissionChecker> {
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService =
isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
serverModel.mediaOk
serverModel.mediaOk && !hideStopService
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
@@ -589,14 +599,15 @@ class _PermissionCheckerState extends State<PermissionChecker> {
label: Text(translate("Stop service")))
.marginOnly(bottom: 8)
: SizedBox.shrink(),
PermissionRow(
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
if (!hideStopService || !serverModel.mediaOk)
PermissionRow(
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(translate("Input Control"), serverModel.inputOk,
serverModel.toggleInput),
PermissionRow(translate("Transfer file"), serverModel.fileOk,
@@ -648,9 +659,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),
@@ -836,13 +846,7 @@ class ClientInfo extends StatelessWidget {
flex: -1,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: CircleAvatar(
backgroundColor: str2color(
client.name,
Theme.of(context).brightness == Brightness.light
? 255
: 150),
child: Text(client.name[0])))),
child: _buildAvatar(context))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -855,6 +859,20 @@ class ClientInfo extends StatelessWidget {
),
]));
}
Widget _buildAvatar(BuildContext context) {
final fallback = CircleAvatar(
backgroundColor: str2color(client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
);
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
) ??
fallback;
}
}
void androidChannelInit() {

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,12 @@ 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;
var _preventSleepWhileConnected = true;
_SettingsState() {
_enableAbr = option2bool(
@@ -109,6 +115,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 +139,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
_preventSleepWhileConnected =
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
_showTerminalExtraKeys =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
}
@override
@@ -200,6 +215,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 +608,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)) {
@@ -649,8 +688,18 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
SettingsTile(
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
? translate('Login')
: '${translate('Logout')} (${gFFI.userModel.userName.value})')),
leading: Icon(Icons.person),
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
leading: Obx(() {
final avatar = bind.mainResolveAvatarUrl(
avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 28,
borderRadius: null,
fallback: Icon(Icons.person),
) ??
Icon(Icons.person);
}),
onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) {
loginDialog();
@@ -667,9 +716,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 +743,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 +818,38 @@ 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 (!incomingOnly)
SettingsTile.switchTile(
title:
Text(translate('keep-awake-during-outgoing-sessions-label')),
initialValue: _preventSleepWhileConnected,
onToggle: (v) async {
await mainSetLocalBoolOption(
kOptionKeepAwakeDuringOutgoingSessions, v);
setState(() {
_preventSleepWhileConnected = v;
});
},
),
]),
if (isAndroid)
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [

View File

@@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/gestures.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/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 +33,18 @@ 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 iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -38,9 +52,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 +76,25 @@ 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);
// Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&
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,38 +104,325 @@ 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 Scaffold(
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false; // Prevent default back behavior
},
child: buildBody(),
);
}
Widget buildBody() {
final scaffold = 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,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by
// default.
//
// Android works fine without this workaround.
deleteDetection: isIOS,
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(),
// iOS-style circular close button in top-right corner
if (isIOS) _buildCloseButton(),
],
),
);
// Add iOS edge swipe gesture to exit (similar to Android back button)
if (isIOS) {
return LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this,
// Only respond to touch input, exclude mouse/trackpad
supportedDevices: kTouchBasedDeviceKinds,
),
(HorizontalDragGestureRecognizer instance) {
instance
// Capture initial touch-down position (before touch slop)
..onDown = (details) {
_swipeStartX = details.localPosition.dx;
_swipeCurrentX = details.localPosition.dx;
}
..onUpdate = (details) {
_swipeCurrentX = details.localPosition.dx;
}
..onEnd = (details) {
// Check if swipe started from left edge and moved right
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
clientClose(sessionId, _ffi);
}
_swipeStartX = 0;
_swipeCurrentX = 0;
}
..onCancel = () {
_swipeStartX = 0;
_swipeCurrentX = 0;
};
},
),
},
child: scaffold,
);
},
);
}
return scaffold;
}
Widget _buildCloseButton() {
return Positioned(
top: 0,
right: 0,
child: SafeArea(
minimum: const EdgeInsets.only(
top: 16, // iOS standard margin
right: 16, // iOS standard margin
),
child: Semantics(
button: true,
label: translate('Close'),
child: Container(
width: 44, // iOS standard tap target size
height: 44,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Half transparency
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
clientClose(sessionId, _ffi);
},
child: Tooltip(
message: translate('Close'),
child: const Icon(
Icons.chevron_left, // iOS-style back arrow
color: Colors.white,
size: 28,
),
),
),
),
),
),
),
);
}
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

View File

@@ -11,7 +11,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
@@ -62,7 +61,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
bool _showGestureHelp = false;
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
final _uniqueKey = UniqueKey();
Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
@@ -100,9 +99,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
if (!isWeb) {
WakelockPlus.enable();
}
WakelockManager.enable(_uniqueKey);
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
@@ -139,9 +136,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
if (!isWeb) {
await WakelockPlus.disable();
}
WakelockManager.disable(_uniqueKey);
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
@@ -197,7 +192,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
@@ -264,13 +259,11 @@ class _ViewCameraPageState extends State<ViewCameraPage>
}
return Container(
color: MyTheme.canvasColor,
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),
@@ -310,7 +303,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI.dialogManager);
clientClose(sessionId, gFFI);
},
),
IconButton(
@@ -590,6 +583,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 +604,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

@@ -12,100 +12,6 @@ void _showSuccess() {
showToast(translate("Successful"));
}
void _showError() {
showToast(translate("Error"));
}
void setPermanentPasswordDialog(OverlayDialogManager dialogManager) async {
final pw = await bind.mainGetPermanentPassword();
final p0 = TextEditingController(text: pw);
final p1 = TextEditingController(text: pw);
var validateLength = false;
var validateSame = false;
dialogManager.show((setState, close, context) {
submit() async {
close();
dialogManager.showLoading(translate("Waiting"));
if (await gFFI.serverModel.setPermanentPassword(p0.text)) {
dialogManager.dismissAll();
_showSuccess();
} else {
dialogManager.dismissAll();
_showError();
}
}
return CustomAlertDialog(
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.password_rounded, color: MyTheme.accent),
Text(translate('Set your own password')).paddingOnly(left: 10),
],
),
content: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextFormField(
autofocus: true,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Password'),
),
controller: p0,
validator: (v) {
if (v == null) return null;
final val = v.trim().length > 5;
if (validateLength != val) {
// use delay to make setState success
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateLength = val));
}
return val
? null
: translate('Too short, at least 6 characters.');
},
).workaroundFreezeLinuxMint(),
TextFormField(
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Confirmation'),
),
controller: p1,
validator: (v) {
if (v == null) return null;
final val = p0.text == v;
if (validateSame != val) {
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateSame = val));
}
return val
? null
: translate('The confirmation is not identical.');
},
).workaroundFreezeLinuxMint(),
])),
onCancel: close,
onSubmit: (validateLength && validateSame) ? submit : null,
actions: [
dialogButton(
'Cancel',
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
),
dialogButton(
'OK',
icon: Icon(Icons.done_rounded),
onPressed: (validateLength && validateSame) ? submit : null,
),
],
);
});
}
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List<String> lengths = ['6', '8', '10'];
@@ -147,18 +53,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 +198,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

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
@@ -53,7 +52,9 @@ class AbModel {
RxBool get currentAbLoading => current.abLoading;
bool get currentAbEmpty => current.peers.isEmpty && current.tags.isEmpty;
RxString get currentAbPullError => current.pullError;
final _listPullError = ''.obs;
RxString get abPullError =>
_listPullError.value.isNotEmpty ? _listPullError : current.pullError;
RxString get currentAbPushError => current.pushError;
String? _personalAbGuid;
RxBool legacyMode = false.obs;
@@ -68,6 +69,7 @@ class AbModel {
var _syncFromRecentLock = false;
var _timerCounter = 0;
var _cacheLoadOnceFlag = false;
var _pulledOnce = false;
var listInitialized = false;
var _maxPeerOneAb = 0;
@@ -97,10 +99,17 @@ class AbModel {
print("reset ab model");
addressbooks.clear();
_currentName.value = '';
_listPullError.value = '';
_pulledOnce = false;
await bind.mainClearAb();
listInitialized = false;
}
void clearPullErrors() {
_listPullError.value = '';
current.pullError.value = '';
}
// #region ab
/// Pulls the address book data from the server.
///
@@ -110,31 +119,41 @@ class AbModel {
var _pulling = false;
Future<void> pullAb(
{required ForcePullAb? force, required bool quiet}) async {
if (bind.isDisableAb()) return;
if (!gFFI.userModel.isLogin) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (_pulling) return;
if (force == null && _pulledOnce) {
return;
}
_pulling = true;
if (!quiet) {
_listPullError.value = '';
current.pullError.value = '';
}
try {
await _pullAb(force: force, quiet: quiet);
_refreshTab();
} catch (_) {}
_pulling = false;
_pulledOnce = true;
}
Future<void> _pullAb(
{required ForcePullAb? force, required bool quiet}) async {
if (bind.isDisableAb()) return;
if (!gFFI.userModel.isLogin) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (force == null && listInitialized && current.initialized) return;
debugPrint("pullAb, force: $force, quiet: $quiet");
if (!listInitialized || force == ForcePullAb.listAndCurrent) {
try {
// Read personal guid every time to avoid upgrading the server without closing the main window
_personalAbGuid = null;
await _getPersonalAbGuid();
// Determine legacy mode based on whether _personalAbGuid is null
// `true`: continue init. `false`: stop, error already recorded.
if (!await _getPersonalAbGuid(quiet: quiet)) {
return;
}
legacyMode.value = _personalAbGuid == null;
if (!legacyMode.value && _maxPeerOneAb == 0) {
await _getAbSettings();
await _getAbSettings(quiet: quiet);
}
if (_personalAbGuid != null) {
debugPrint("pull ab list");
@@ -142,7 +161,7 @@ class AbModel {
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
gFFI.userModel.userName.value, null, ShareRule.read.value, null));
// get all address book name
await _getSharedAbProfiles(abProfiles);
await _getSharedAbProfiles(abProfiles, quiet: quiet);
addressbooks.removeWhere((key, value) =>
abProfiles.firstWhereOrNull((e) => e.name == key) == null);
for (int i = 0; i < abProfiles.length; i++) {
@@ -182,6 +201,7 @@ class AbModel {
}
} catch (e) {
debugPrint("pull ab list error: $e");
_setListPullError(e, quiet: quiet);
}
} else if (listInitialized &&
(!current.initialized || force == ForcePullAb.current)) {
@@ -197,13 +217,26 @@ class AbModel {
}
}
Future<bool> _getAbSettings() async {
void _setListPullError(Object err, {required bool quiet, int? statusCode}) {
if (!quiet) {
_listPullError.value =
'${translate('pull_ab_failed_tip')}: ${translate(err.toString())}';
}
if (statusCode == 401) {
gFFI.userModel.reset(resetOther: true);
}
}
Future<bool> _getAbSettings({required bool quiet}) async {
int? statusCode;
try {
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) {
statusCode = resp.statusCode;
if (statusCode == 404) {
debugPrint("HTTP 404, api server doesn't support shared address book");
return false;
}
@@ -212,45 +245,57 @@ class AbModel {
if (json.containsKey('error')) {
throw json['error'];
}
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
if (statusCode != 200) {
throw 'HTTP $statusCode';
}
_maxPeerOneAb = json['max_peer_one_ab'] ?? 0;
return true;
} catch (err) {
debugPrint('get ab settings err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}
Future<bool> _getPersonalAbGuid() async {
/// Loads `/api/ab/personal`.
/// Returns `true` to continue init, `false` to stop after a real error.
Future<bool> _getPersonalAbGuid({required bool quiet}) async {
int? statusCode;
try {
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) {
statusCode = resp.statusCode;
if (statusCode == 404) {
debugPrint("HTTP 404, current api server is legacy mode");
return false;
// Old server: keep `_personalAbGuid` null and continue in legacy mode.
return true;
}
Map<String, dynamic> json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
if (statusCode != 200) {
throw 'HTTP $statusCode';
}
_personalAbGuid = json['guid'];
// New server: guid is available, continue in non-legacy mode.
return true;
} catch (err) {
debugPrint('get personal ab err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
// Real error: stop the current pull.
return false;
}
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles) async {
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles,
{required bool quiet}) async {
final api = "${await bind.mainGetApiServer()}/api/ab/shared/profiles";
int? statusCode;
try {
var uri0 = Uri.parse(api);
final pageSize = 100;
@@ -269,14 +314,21 @@ class AbModel {
});
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
if (statusCode == 404) {
debugPrint(
"HTTP 404, api server doesn't support shared address book");
return false;
}
Map<String, dynamic> json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
if (statusCode != 200) {
throw 'HTTP $statusCode';
}
if (json.containsKey('total')) {
if (total == 0) total = json['total'];
@@ -299,6 +351,7 @@ class AbModel {
return true;
} catch (err) {
debugPrint('_getSharedAbProfiles err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}
@@ -1012,16 +1065,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 +1451,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 +1509,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 +2024,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);
}
@@ -1033,30 +1074,54 @@ class JobController {
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
}
void loadLastJob(Map<String, dynamic> evt) {
Future<void> loadLastJob(Map<String, dynamic> evt) async {
debugPrint("load last job: $evt");
Map<String, dynamic> jobDetail = json.decode(evt['value']);
// int id = int.parse(jobDetail['id']);
String remote = jobDetail['remote'];
String to = jobDetail['to'];
bool showHidden = jobDetail['show_hidden'];
int fileNum = jobDetail['file_num'];
bool isRemote = jobDetail['is_remote'];
final currJobId = JobController.jobID.next();
String fileName = path.basename(isRemote ? remote : to);
var jobProgress = JobProgress()
..type = JobType.transfer
..fileName = fileName
..jobName = isRemote ? remote : to
..id = currJobId
..isRemoteToLocal = isRemote
..fileNum = fileNum
..remote = remote
..to = to
..showHidden = showHidden
..state = JobState.paused;
jobTable.add(jobProgress);
bind.sessionAddJob(
bool isAutoStart = jobDetail['auto_start'] == true;
int currJobId = -1;
if (isAutoStart) {
// Ensure jobDetail['id'] exists and is an int
if (jobDetail.containsKey('id') &&
jobDetail['id'] != null &&
jobDetail['id'] is int) {
currJobId = jobDetail['id'];
}
}
if (currJobId < 0) {
// If id is missing or invalid, disable auto-start and assign a new job id
isAutoStart = false;
currJobId = JobController.jobID.next();
}
if (!isAutoStart) {
if (!(isDesktop || isWebDesktop)) {
// Don't add to job table if not auto start on mobile.
// Because mobile does not support job list view now.
return;
}
// Add to job table if not auto start on desktop.
String fileName = path.basename(isRemote ? remote : to);
final jobProgress = JobProgress()
..type = JobType.transfer
..fileName = fileName
..jobName = isRemote ? remote : to
..id = currJobId
..isRemoteToLocal = isRemote
..fileNum = fileNum
..remote = remote
..to = to
..showHidden = showHidden
..state = JobState.paused;
jobTable.add(jobProgress);
}
await bind.sessionAddJob(
sessionId: sessionId,
isRemote: isRemote,
includeHidden: showHidden,
@@ -1065,6 +1130,11 @@ class JobController {
to: isRemote ? to : remote,
fileNum: fileNum,
);
if (isAutoStart) {
await bind.sessionResumeJob(
sessionId: sessionId, actId: currJobId, isRemote: isRemote);
}
}
void resumeJob(int jobId) {
@@ -1095,6 +1165,11 @@ class JobController {
}
debugPrint("update folder files: $info");
}
void clear() {
jobTable.clear();
jobResultListener.clear();
}
}
class JobResultListener<T> {
@@ -1241,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 {
@@ -1404,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

@@ -343,6 +343,7 @@ class GroupModel {
}
reset() async {
initialized = false;
groupLoadError.value = '';
deviceGroups.clear();
users.clear();

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -14,11 +15,14 @@ import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'input_modifier_utils.dart';
import 'relative_mouse_model.dart';
import '../common.dart';
import '../consts.dart';
/// Mouse button enum.
enum MouseButtons { left, right, wheel, back }
enum MouseButtons { left, right, wheel, back, forward }
const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup';
@@ -42,8 +46,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 +61,8 @@ 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;
}
@@ -157,6 +159,8 @@ extension ToString on MouseButtons {
return 'wheel';
case MouseButtons.back:
return 'back';
case MouseButtons.forward:
return 'forward';
}
}
}
@@ -327,6 +331,80 @@ class ToReleaseKeys {
}
class InputModel {
// Side mouse button support for Linux.
// Flutter's Linux embedder drops X11 button 8/9 events, so we capture them
// natively via GDK and forward through the platform channel.
static InputModel? _activeSideButtonModel;
// Tracks per-button which model received a side button down event, so the
// matching up event is routed there even if the pointer has left the view
// or a different button was pressed in between.
static final Map<MouseButtons, InputModel> _sideButtonDownModels = {};
static bool _sideButtonChannelInitialized = false;
/// Each Flutter engine (main window + sub-windows from desktop_multi_window)
/// runs its own Dart isolate with its own statics. Called from initEnv()
/// which runs per-engine, so each isolate registers its own handler tied
/// to its own set of InputModels.
static void initSideButtonChannel() {
if (!Platform.isLinux) return;
if (_sideButtonChannelInitialized) return;
_sideButtonChannelInitialized = true;
const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons');
channel.setMethodCallHandler((call) async {
if (call.method == 'onSideMouseButton') {
final args = call.arguments as Map<dynamic, dynamic>;
final button = args['button'] as String;
final type = args['type'] as String;
final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward;
if (type == 'down') {
final model = _activeSideButtonModel;
if (model != null &&
!(model.isViewOnly && !model.showMyCursor) &&
model.keyboardPerm &&
!model.isViewCamera) {
_sideButtonDownModels[mb] = model;
// Fire-and-forget to avoid blocking the platform channel handler.
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
} else {
// Only route 'up' when we recorded the matching 'down';
// dropping avoids sending unpaired 'up' to an unrelated session.
// Use _sendMouseUnchecked to bypass permission checks so the
// release always goes through even if permissions changed.
final model = _sideButtonDownModels.remove(mb);
if (model != null) {
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
}
}
return null;
});
}
/// Clear any static references to this model (prevents stale routing).
/// Releases any held side buttons on the peer so closing a session
/// mid-press does not leave a stuck button.
void disposeSideButtonTracking() {
if (_activeSideButtonModel == this) _activeSideButtonModel = null;
final held = _sideButtonDownModels.entries
.where((e) => e.value == this)
.map((e) => e.key)
.toList();
for (final mb in held) {
_sideButtonDownModels.remove(mb);
// Best-effort release; session may already be tearing down.
unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) {
debugPrint('[InputModel] failed to release side button $mb: $e');
}));
}
}
final WeakReference<FFI> parent;
String keyboardMode = '';
@@ -348,18 +426,47 @@ class InputModel {
final _trackpadAdjustPeerLinux = 0.06;
// This is an experience value.
final _trackpadAdjustMacToWin = 2.50;
// Ignore directional locking for very small deltas on both axes (including
// tiny single-axis movement) to avoid over-filtering near zero.
static const double _trackpadAxisNoiseThreshold = 0.2;
// Lock to dominant axis only when one axis is clearly stronger.
// 1.6 means the dominant axis must be >= 60% larger than the other.
static const double _trackpadAxisLockRatio = 1.6;
int _trackpadSpeed = kDefaultTrackpadSpeed;
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;
int _lastWheelTsUs = 0;
// Wheel acceleration thresholds.
static const int _wheelAccelFastThresholdUs = 40000; // 40ms
static const int _wheelAccelMediumThresholdUs = 80000; // 80ms
static const double _wheelBurstVelocityThreshold =
0.002; // delta units per microsecond
// Wheel burst acceleration (empirical tuning).
// Applies only to fast, non-smooth bursts to preserve single-step scrolling.
// Flutter uses microseconds for dt, so velocity is in delta/us.
// 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;
@@ -370,14 +477,109 @@ 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) {
initSideButtonChannel();
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;
}
});
}
// https://github.com/flutter/flutter/issues/157241
// Infer CapsLock state from the character output.
// This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
// incorrect CapsLock state on iOS.
bool _getIosCapsFromCharacter(KeyEvent e) {
if (!isIOS) return false;
final ch = e.character;
return _getIosCapsFromCharacterImpl(
ch, HardwareKeyboard.instance.isShiftPressed);
}
// RawKeyEvent version of _getIosCapsFromCharacter.
bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
if (!isIOS) return false;
final ch = e.character;
return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
}
// Shared implementation for inferring CapsLock state from character.
// Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
//
// Limitations:
// 1. This inference assumes the client and server use the same keyboard layout.
// If layouts differ (e.g., client uses EN, server uses DE), the character output
// may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
// layout, making it impossible to correctly infer CapsLock state from the
// character alone.
// 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
// produces lowercase). This method cannot handle that case correctly.
bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
if (ch == null || ch.length != 1) return false;
// Use Dart's built-in Unicode-aware case detection
final upper = ch.toUpperCase();
final lower = ch.toLowerCase();
final isUpper = upper == ch && lower != ch;
final isLower = lower == ch && upper != ch;
// Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
if (!isUpper && !isLower) return false;
return isUpper != shiftPressed;
}
int _buildLockModes(bool iosCapsLock) {
const capslock = 1;
const numlock = 2;
const scrolllock = 3;
int lockModes = 0;
if (isIOS) {
if (iosCapsLock) {
lockModes |= (1 << capslock);
}
// Ignore "NumLock/ScrollLock" on iOS for now.
} else {
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.capsLock)) {
lockModes |= (1 << capslock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.numLock)) {
lockModes |= (1 << numlock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
}
return lockModes;
}
// This function must be called after the peer info is received.
@@ -497,6 +699,38 @@ class InputModel {
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedShiftKeyEventIfNeeded() {
final leftShift = toReleaseKeys.lastLShiftKeyEvent;
final rightShift = toReleaseKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleKeyEvent(leftShift);
}
if (rightShift != null) {
handleKeyEvent(rightShift);
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The raw key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedRawShiftKeyEventIfNeeded() {
final leftShift = toReleaseRawKeys.lastLShiftKeyEvent;
final rightShift = toReleaseRawKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: leftShift.data,
character: leftShift.character,
));
}
if (rightShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: rightShift.data,
character: rightShift.character,
));
}
}
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
@@ -508,6 +742,15 @@ class InputModel {
}
}
if (_relativeMouse.handleRawKeyEvent(e)) {
return KeyEventResult.handled;
}
bool iosCapsLock = false;
if (isIOS && e is RawKeyDownEvent) {
iosCapsLock = _getIosCapsFromRawCharacter(e);
}
final key = e.logicalKey;
if (e is RawKeyDownEvent) {
if (!e.repeat) {
@@ -542,9 +785,30 @@ class InputModel {
toReleaseRawKeys.updateKeyUp(key, e);
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current raw key event is not shifted anymore.
if (e is RawKeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: e.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null ||
toReleaseRawKeys.lastRShiftKeyEvent != null,
)) {
if (kDebugMode) {
debugPrint(
'input: releasing stale mobile Shift before replaying tracked raw '
'key-up (logicalKey=${e.logicalKey.keyLabel}, '
'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)',
);
}
_releaseTrackedRawShiftKeyEventIfNeeded();
}
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
mapKeyboardModeRaw(e);
mapKeyboardModeRaw(e, iosCapsLock);
} else {
legacyKeyboardModeRaw(e);
}
@@ -570,6 +834,23 @@ class InputModel {
}
}
if (_relativeMouse.handleKeyEvent(
e,
ctrlPressed: ctrl,
shiftPressed: shift,
altPressed: alt,
commandPressed: command,
)) {
return KeyEventResult.handled;
}
bool iosCapsLock = false;
if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
iosCapsLock = _getIosCapsFromCharacter(e);
}
// Update cached modifier state before sending the event. The stale mobile
// Shift release check below relies on this cached state.
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
@@ -607,6 +888,21 @@ class InputModel {
}
}
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current key event is not shifted anymore.
if (e is KeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: HardwareKeyboard.instance.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null ||
toReleaseKeys.lastRShiftKeyEvent != null,
)) {
_releaseTrackedShiftKeyEventIfNeeded();
}
final isDesktopAndMapMode =
isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode);
if (isMobileAndMapMode || isDesktopAndMapMode) {
@@ -615,7 +911,8 @@ class InputModel {
e.character ?? '',
e.physicalKey.usbHidUsage & 0xFFFF,
// Show repeat event be converted to "release+press" events?
e is KeyDownEvent || e is KeyRepeatEvent);
e is KeyDownEvent || e is KeyRepeatEvent,
iosCapsLock);
} else {
legacyKeyboardMode(e);
}
@@ -624,23 +921,9 @@ class InputModel {
}
/// Send Key Event
void newKeyboardMode(String character, int usbHid, bool down) {
const capslock = 1;
const numlock = 2;
const scrolllock = 3;
int lockModes = 0;
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.capsLock)) {
lockModes |= (1 << capslock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.numLock)) {
lockModes |= (1 << numlock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
void newKeyboardMode(
String character, int usbHid, bool down, bool iosCapsLock) {
final lockModes = _buildLockModes(iosCapsLock);
bind.sessionHandleFlutterKeyEvent(
sessionId: sessionId,
character: character,
@@ -649,7 +932,7 @@ class InputModel {
downOrUp: down);
}
void mapKeyboardModeRaw(RawKeyEvent e) {
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
int positionCode = -1;
int platformCode = -1;
bool down;
@@ -680,27 +963,14 @@ class InputModel {
} else {
down = false;
}
inputRawKey(e.character ?? '', platformCode, positionCode, down);
inputRawKey(
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
}
/// Send raw Key Event
void inputRawKey(String name, int platformCode, int positionCode, bool down) {
const capslock = 1;
const numlock = 2;
const scrolllock = 3;
int lockModes = 0;
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.capsLock)) {
lockModes |= (1 << capslock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.numLock)) {
lockModes |= (1 << numlock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
void inputRawKey(String name, int platformCode, int positionCode, bool down,
bool iosCapsLock) {
final lockModes = _buildLockModes(iosCapsLock);
bind.sessionHandleFlutterRawKeyEvent(
sessionId: sessionId,
name: name,
@@ -774,6 +1044,9 @@ class InputModel {
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
final Map<String, dynamic> out = {};
bool hasStaleButtonsOnMouseUp =
type == _kMouseEventUp && evt.buttons == _lastButtons;
// Check update event type and set buttons to be sent.
int buttons = _lastButtons;
if (type == _kMouseEventMove) {
@@ -798,7 +1071,7 @@ class InputModel {
buttons = evt.buttons;
}
}
_lastButtons = evt.buttons;
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
out['buttons'] = buttons;
out['type'] = type;
@@ -842,24 +1115,41 @@ class InputModel {
return evt;
}
/// Send mouse event unconditionally (no permission checks).
/// Used for side button releases that must go through even if permissions
/// changed after the matching down was sent.
Future<void> _sendMouseUnchecked(String type, MouseButtons button) async {
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
}
/// Send mouse press event.
Future<void> sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
await _sendMouseUnchecked(type, button);
}
void enterOrLeave(bool enter) {
toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
_pointerInsideImage = enter;
_lastWheelTsUs = 0;
// Track active model for side button events (Linux).
if (enter) {
_activeSideButtonModel = this;
} else if (_activeSideButtonModel == this) {
_activeSideButtonModel = null;
}
// Fix status
if (!enter) {
resetModifiers();
}
_relativeMouse.onEnterOrLeaveImage(enter);
_flingTimer?.cancel();
if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
@@ -880,15 +1170,142 @@ 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;
// May fix https://github.com/rustdesk/rustdesk/issues/13009
if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) {
// iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected.
// Ignore this event to prevent cursor jumping.
debugPrint('Ignored synthesized hover at (0,0) on iOS');
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);
}
}
}
@@ -924,6 +1341,7 @@ class InputModel {
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
delta *= _trackpadAdjustMacToWin;
}
delta = _filterTrackpadDeltaAxis(delta);
_trackpadLastDelta = delta;
var x = delta.dx.toInt();
@@ -956,6 +1374,24 @@ class InputModel {
}
}
Offset _filterTrackpadDeltaAxis(Offset delta) {
final absDx = delta.dx.abs();
final absDy = delta.dy.abs();
// Keep diagonal intent when movement is tiny on both axes.
if (absDx < _trackpadAxisNoiseThreshold &&
absDy < _trackpadAxisNoiseThreshold) {
return delta;
}
// Dominant-axis lock to reduce accidental cross-axis scrolling noise.
if (absDy >= absDx * _trackpadAxisLockRatio) {
return Offset(0, delta.dy);
}
if (absDx >= absDy * _trackpadAxisLockRatio) {
return Offset(delta.dx, 0);
}
return delta;
}
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {
@@ -1037,6 +1473,28 @@ class InputModel {
_trackpadLastDelta = Offset.zero;
}
// iOS Magic Mouse duplicate event detection.
// When using Magic Mouse on iPad, iOS may emit both mouse and touch events
// for the same click in certain areas (like top-left corner).
int _lastMouseDownTimeMs = 0;
ui.Offset _lastMouseDownPos = ui.Offset.zero;
/// Check if a touch tap event should be ignored because it's a duplicate
/// of a recent mouse event (iOS Magic Mouse issue).
bool shouldIgnoreTouchTap(ui.Offset pos) {
if (!isIOS) return false;
final nowMs = DateTime.now().millisecondsSinceEpoch;
final dt = nowMs - _lastMouseDownTimeMs;
final distance = (_lastMouseDownPos - pos).distance;
// If touch tap is within 2000ms and 80px of the last mouse down,
// it's likely a duplicate event from the same Magic Mouse click.
if (dt >= 0 && dt < 2000 && distance < 80.0) {
debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
return true;
}
return false;
}
void onPointDownImage(PointerDownEvent e) {
debugPrint("onPointDownImage ${e.kind}");
_stopFling = true;
@@ -1045,13 +1503,32 @@ class InputModel {
_windowRect = null;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
// Track mouse down events for duplicate detection on iOS.
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (e.kind == ui.PointerDeviceKind.mouse) {
_lastMouseDownTimeMs = nowMs;
_lastMouseDownPos = e.position;
}
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 +1536,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 +1558,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 +1570,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,21 +1597,53 @@ 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;
if (e is PointerScrollEvent) {
var dx = e.scrollDelta.dx.toInt();
var dy = e.scrollDelta.dy.toInt();
final rawDx = e.scrollDelta.dx;
final rawDy = e.scrollDelta.dy;
final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs();
final isSmooth = dominantDelta < 1;
final nowUs = DateTime.now().microsecondsSinceEpoch;
final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs;
_lastWheelTsUs = nowUs;
int accel = 1;
if (!isSmooth &&
dtUs > 0 &&
dtUs <= _wheelAccelMediumThresholdUs &&
(isWindows || isLinux) &&
peerPlatform == kPeerPlatformMacOS) {
final velocity = dominantDelta / dtUs;
if (velocity >= _wheelBurstVelocityThreshold) {
if (dtUs < _wheelAccelFastThresholdUs) {
accel = 3;
} else {
accel = 2;
}
}
}
var dx = rawDx.toInt();
var dy = rawDy.toInt();
if (rawDx.abs() > rawDy.abs()) {
dy = 0;
} else {
dx = 0;
}
if (dx > 0) {
dx = -1;
dx = -accel;
} else if (dx < 0) {
dx = 1;
dx = accel;
}
if (dy > 0) {
dy = -1;
dy = -accel;
} else if (dy < 0) {
dy = 1;
dy = accel;
}
bind.sessionSendMouse(
sessionId: sessionId,
@@ -1125,7 +1654,7 @@ class InputModel {
void refreshMousePos() => handleMouse({
'buttons': 0,
'type': _kMouseEventMove,
}, lastMousePos);
}, lastMousePos, edgeScroll: useEdgeScroll);
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
{
@@ -1232,6 +1761,7 @@ class InputModel {
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
if (isViewCamera) return null;
double x = offset.dx;
@@ -1273,6 +1803,7 @@ class InputModel {
onExit: onExit,
buttons: evt['buttons'],
moveCanvas: moveCanvas,
edgeScroll: edgeScroll,
);
if (pos == null) {
return null;
@@ -1285,14 +1816,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 +1836,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 +1856,7 @@ class InputModel {
bool onExit = false,
int buttons = kPrimaryMouseButton,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
final ffiModel = parent.target!.ffiModel;
CanvasCoords canvas =
@@ -1348,8 +1885,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 +1957,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;
@@ -1534,9 +2079,9 @@ class InputModel {
// Simulate a key press event.
// `usbHidUsage` is the USB HID usage code of the key.
Future<void> tapHidKey(int usbHidUsage) async {
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
await Future.delayed(Duration(milliseconds: 100));
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
}
Future<void> onMobileVolumeUp() async =>

View File

@@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
/// Returns true when a stale mobile one-shot Shift state should be released
/// by replaying a tracked Shift key-down as a synthesized key-up.
///
/// This is only valid on mobile when Flutter's cached Shift state is still on
/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
/// Shift as off (`actualShiftPressed == false`).
///
/// A tracked Shift key-down is required so the caller can safely synthesize the
/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
/// Shift key event itself must be processed first; otherwise we could release
/// the tracked key while still handling the original Shift press/release.
/// Callers should evaluate this only after their cached modifier state has been
/// updated for the current event.
///
/// When this returns true, the caller logs a line like:
/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
bool shouldReleaseStaleMobileShift({
required bool isMobile,
required bool cachedShiftPressed,
required bool actualShiftPressed,
required LogicalKeyboardKey logicalKey,
required bool hasTrackedShiftKeyDown,
}) {
if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
return false;
}
if (!hasTrackedShiftKeyDown) {
return false;
}
if (logicalKey == LogicalKeyboardKey.shiftLeft ||
logicalKey == LogicalKeyboardKey.shiftRight) {
return false;
}
return true;
}

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;
@@ -117,6 +120,7 @@ class FfiModel with ChangeNotifier {
late VirtualMouseMode virtualMouseMode;
Timer? _timer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference<FFI> parent;
@@ -156,6 +160,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 +182,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 +214,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 +225,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 +379,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 +473,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 +784,8 @@ 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 +797,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 +893,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') {
@@ -892,11 +941,46 @@ class FfiModel with ChangeNotifier {
showPrivacyFailedDialog(
sessionId, type, title, text, link, hasRetry, dialogManager);
} else {
final hasRetry = evt['hasRetry'] == 'true';
var hasRetry = evt['hasRetry'] == 'true';
if (!hasRetry) {
hasRetry = shouldAutoRetryOnOffline(type, title, text);
}
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
}
}
/// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline(
String type,
String title,
String text,
) {
if (type == 'error' &&
title == 'Connection Error' &&
text == 'Remote desktop is offline' &&
_pi.isSet.isTrue) {
// Auto retry for ~30s (server's peer offline threshold) when controlled peer's account changes
// (e.g., signout, switch user, login into OS) causes temporary offline via websocket/tcp connection.
// The actual wait may exceed 30s (e.g., 20s elapsed + 16s next retry = 36s), which is acceptable
// since the controlled side reconnects quickly after account changes.
// Uses time-based check instead of _reconnects count because user can manually retry.
// https://github.com/rustdesk/rustdesk/discussions/14048
if (_offlineReconnectStartTime == null) {
// First offline, record time and start retry
_offlineReconnectStartTime = DateTime.now();
return true;
} else {
final elapsed =
DateTime.now().difference(_offlineReconnectStartTime!).inSeconds;
if (elapsed < 30) {
return true;
}
}
}
return false;
}
handleToast(Map<String, dynamic> evt, SessionID sessionId, String peerId) {
final type = evt['type'] ?? 'info';
final text = evt['text'] ?? '';
@@ -931,11 +1015,33 @@ 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 noteAllowed = parent.target != null &&
allowAskForNoteAtEndOfConnection(parent.target, false) &&
(title == "Connection Error" || type == "restarting");
final showNoteEdit = noteAllowed && !hasRetry;
if (showNoteEdit) {
await showConnEndAuditDialogCloseCanceled(
ffi: parent.target!, type: type, title: title, text: text);
closeConnection();
} else {
VoidCallback? onSubmit;
if (noteAllowed && hasRetry) {
final ffi = parent.target!;
onSubmit = () async {
_timer?.cancel();
_timer = null;
await showConnEndAuditDialogCloseCanceled(
ffi: ffi, type: type, title: title, text: text);
closeConnection();
};
}
msgBox(sessionId, type, title, text, link, dialogManager,
hasCancel: hasCancel,
reconnect: hasRetry ? reconnect : null,
reconnectTimeout: hasRetry ? _reconnects : null,
onSubmit: onSubmit);
}
_timer?.cancel();
if (hasRetry) {
_timer = Timer(Duration(seconds: _reconnects), () {
@@ -944,11 +1050,14 @@ class FfiModel with ChangeNotifier {
_reconnects *= 2;
} else {
_reconnects = 1;
_offlineReconnectStartTime = null;
}
}
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 +1065,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 +1097,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 +1174,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 +1294,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'];
@@ -1148,6 +1373,7 @@ class FfiModel with ChangeNotifier {
}
if (displays.isNotEmpty) {
_reconnects = 1;
_offlineReconnectStartTime = null;
waitForFirstImage.value = true;
isRefreshing = false;
}
@@ -1181,7 +1407,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 +1553,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 +1904,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 +1953,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 +2077,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,9 +2152,21 @@ 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;
Timer? _timerMobileRestoreCanvasOffset;
Offset? _offsetBeforeMobileSoftKeyboard;
double? _scaleBeforeMobileSoftKeyboard;
// `isMobileCanvasChanged` is used to avoid canvas reset when changing the input method
// after showing the soft keyboard.
@@ -1840,9 +2194,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;
@@ -1867,10 +2230,32 @@ class CanvasModel with ChangeNotifier {
double w = size.width - leftToEdge - rightToEdge;
double h = size.height - topToEdge - bottomToEdge;
if (isMobile) {
// Account for horizontal safe area insets on both orientations.
w = w - mediaData.padding.left - mediaData.padding.right;
// Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
h = h -
mediaData.viewInsets.bottom -
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
0);
// Orientation-specific handling:
// - Portrait: additionally subtract top padding (e.g. status bar / notch)
// - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides)
final isPortrait = size.height > size.width;
if (isPortrait) {
// In portrait mode, subtract the top safe-area padding (e.g. status bar / notch)
// so the remote image is not truncated, while keeping the bottom inset to avoid
// introducing unnecessary blank space around the canvas.
//
// iOS -> Android, portrait, adjust mode:
// h = h (no padding subtracted): top and bottom are truncated
// https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998
// h = h - top - bottom: extra blank spaces appear
// https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e
// h = h - top (current): works fine
// https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81
h = h - mediaData.padding.top;
}
}
return Size(w < 0 ? 0 : w, h < 0 ? 0 : h);
}
@@ -1957,30 +2342,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 +2409,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 +2464,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();
}
@@ -2122,6 +2654,9 @@ class CanvasModel with ChangeNotifier {
_scale = 1.0;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel();
_offsetBeforeMobileSoftKeyboard = null;
_scaleBeforeMobileSoftKeyboard = null;
}
updateScrollPercent() {
@@ -2150,6 +2685,31 @@ class CanvasModel with ChangeNotifier {
});
}
void saveMobileOffsetBeforeSoftKeyboard() {
_timerMobileRestoreCanvasOffset?.cancel();
_offsetBeforeMobileSoftKeyboard = Offset(_x, _y);
_scaleBeforeMobileSoftKeyboard = _scale;
}
void restoreMobileOffsetAfterSoftKeyboard() {
_timerMobileRestoreCanvasOffset?.cancel();
_timerMobileFocusCanvasCursor?.cancel();
final targetOffset = _offsetBeforeMobileSoftKeyboard;
final targetScale = _scaleBeforeMobileSoftKeyboard;
if (targetOffset == null || targetScale == null) {
return;
}
_timerMobileRestoreCanvasOffset = Timer(Duration(milliseconds: 100), () {
updateSize();
_x = targetOffset.dx;
_y = targetOffset.dy;
_scale = targetScale;
_offsetBeforeMobileSoftKeyboard = null;
_scaleBeforeMobileSoftKeyboard = null;
notifyListeners();
});
}
// mobile only
// Move the canvas to make the cursor visible(center) on the screen.
void _moveToCenterCursor() {
@@ -2402,8 +2962,13 @@ class CursorModel with ChangeNotifier {
_lastIsBlocked = true;
}
if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) {
parent.target?.canvasModel.mobileFocusCanvasCursor();
parent.target?.canvasModel.isMobileCanvasChanged = false;
if (keyboardIsVisible) {
parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard();
parent.target?.canvasModel.mobileFocusCanvasCursor();
parent.target?.canvasModel.isMobileCanvasChanged = false;
} else {
parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard();
}
}
_lastKeyboardIsVisible = keyboardIsVisible;
}
@@ -2590,9 +3155,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 +3180,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 +3601,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 +3691,6 @@ class FFI {
List<int>? displays,
}) {
closed = false;
auditNote = '';
if (isMobile) mobileReset();
assert(
(!(isPortForward && isViewCamera)) &&
@@ -3318,6 +3882,7 @@ class FFI {
dialogManager.dismissAll();
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
@@ -3365,6 +3930,9 @@ class FFI {
ffiModel.clear();
canvasModel.clear();
inputModel.resetModifiers();
// Dispose relative mouse mode resources to ensure cursor is restored
inputModel.disposeRelativeMouseMode();
inputModel.disposeSideButtonTracking();
if (closeSession) {
await bind.sessionClose(sessionId: sessionId);
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../common.dart';
@@ -51,6 +50,8 @@ class ServerModel with ChangeNotifier {
Timer? cmHiddenTimer;
final _wakelockKey = UniqueKey();
bool get isStart => _isStart;
bool get mediaOk => _mediaOk;
@@ -466,21 +467,8 @@ class ServerModel with ChangeNotifier {
await parent.target?.invokeMethod("stop_service");
await bind.mainStopService();
notifyListeners();
if (!isLinux) {
// current linux is not supported
WakelockPlus.disable();
}
}
Future<bool> setPermanentPassword(String newPW) async {
await bind.mainSetPermanentPassword(password: newPW);
await Future.delayed(Duration(milliseconds: 500));
final pw = await bind.mainGetPermanentPassword();
if (newPW == pw) {
return true;
} else {
return false;
}
// for androidUpdatekeepScreenOn only
WakelockManager.disable(_wakelockKey);
}
fetchID() async {
@@ -613,12 +601,12 @@ class ServerModel with ChangeNotifier {
void showLoginDialog(Client client) {
showClientDialog(
client,
client.isFileTransfer
? "Transfer file"
client.isFileTransfer
? "Transfer file"
: client.isViewCamera
? "View camera"
: client.isTerminal
? "Terminal"
: client.isTerminal
? "Terminal"
: "Share screen",
'Do you accept?',
'android_new_connection_tip',
@@ -797,12 +785,10 @@ class ServerModel with ChangeNotifier {
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
(keepScreenOn == KeepScreenOn.duringControlled &&
_clients.map((e) => !e.disconnected).isNotEmpty);
if (on != await WakelockPlus.enabled) {
if (on) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
if (on) {
WakelockManager.enable(_wakelockKey, isServer: true);
} else {
WakelockManager.disable(_wakelockKey);
}
}
}
@@ -823,6 +809,7 @@ class Client {
bool isTerminal = false;
String portForward = "";
String name = "";
String avatar = "";
String peerId = ""; // peer user's id,show at app
bool keyboard = false;
bool clipboard = false;
@@ -850,6 +837,7 @@ class Client {
isTerminal = json['is_terminal'] ?? false;
portForward = json['port_forward'];
name = json['name'];
avatar = json['avatar'] ?? '';
peerId = json['peer_id'];
keyboard = json['keyboard'];
clipboard = json['clipboard'];
@@ -873,6 +861,7 @@ class Client {
data['is_terminal'] = isTerminal;
data['port_forward'] = portForward;
data['name'] = name;
data['avatar'] = avatar;
data['peer_id'] = peerId;
data['keyboard'] = keyboard;
data['clipboard'] = clipboard;

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

@@ -24,9 +24,18 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false;
final _inputBuffer = <String>[];
// Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[];
int _pendingOutputSize = 0;
static const int _kMaxOutputBufferChars = 8 * 1024;
// View ready state: true when terminal has valid dimensions, safe to write
bool _terminalViewReady = false;
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 +77,16 @@ 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);
// Mark terminal view as ready and flush any buffered output on first valid resize.
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
if (!_terminalViewReady) {
_markViewReady();
}
if (_terminalOpened) {
// Notify remote terminal of resize
try {
@@ -135,11 +154,15 @@ class TerminalModel with ChangeNotifier {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user
if (e is TimeoutException) {
terminal.write('Failed to open terminal: Connection timeout\r\n');
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
}
}
}
Future<void> sendVirtualKey(String data) async {
return _handleInput(data);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {
@@ -243,8 +266,8 @@ class TerminalModel with ChangeNotifier {
void _handleTerminalOpened(Map<String, dynamic> evt) {
final bool success = getSuccessFromEvt(evt);
final String message = evt['message'] ?? '';
final String? serviceId = evt['service_id'];
final String message = evt['message']?.toString() ?? '';
final String? serviceId = evt['service_id']?.toString();
debugPrint(
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
@@ -252,7 +275,18 @@ class TerminalModel with ChangeNotifier {
if (success) {
_terminalOpened = true;
// Service ID is now saved on the Rust side in handle_terminal_response
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
// We intentionally accept this tradeoff for now to keep logic simple.
// Fallback: if terminal view is not yet ready but already has valid
// dimensions (e.g. layout completed before open response arrived),
// mark view ready now to avoid output stuck in buffer indefinitely.
if (!_terminalViewReady &&
terminal.viewWidth > 0 &&
terminal.viewHeight > 0) {
_markViewReady();
}
// Process any buffered input
_processBufferedInputAsync().then((_) {
@@ -273,7 +307,7 @@ class TerminalModel with ChangeNotifier {
}));
}
} else {
terminal.write('Failed to open terminal: $message\r\n');
_writeToTerminal('Failed to open terminal: $message\r\n');
}
}
@@ -317,29 +351,82 @@ class TerminalModel with ChangeNotifier {
return;
}
terminal.write(text);
_writeToTerminal(text);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
}
}
/// Write text to terminal, buffering if the view is not yet ready.
/// All terminal output should go through this method to avoid NaN errors
/// from writing before the terminal view has valid layout dimensions.
void _writeToTerminal(String text) {
if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence,
// which can cause a brief visual glitch on flush. This is acceptable
// because it only affects the pre-layout buffering window and the
// terminal will self-correct on subsequent output.
if (text.length >= _kMaxOutputBufferChars) {
final truncated = text.substring(text.length - _kMaxOutputBufferChars);
_pendingOutputChunks
..clear()
..add(truncated);
_pendingOutputSize = truncated.length;
} else {
_pendingOutputChunks.add(text);
_pendingOutputSize += text.length;
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
while (_pendingOutputSize > _kMaxOutputBufferChars &&
_pendingOutputChunks.length > 1) {
final removed = _pendingOutputChunks.removeAt(0);
_pendingOutputSize -= removed.length;
}
}
return;
}
terminal.write(text);
}
void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return;
debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) {
terminal.write(chunk);
}
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
}
/// Mark terminal view as ready and flush buffered output.
void _markViewReady() {
if (_terminalViewReady) return;
_terminalViewReady = true;
_flushOutputBuffer();
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0;
terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
}
void _handleTerminalError(Map<String, dynamic> evt) {
final String message = evt['message'] ?? 'Unknown error';
terminal.write('\r\nTerminal error: $message\r\n');
_writeToTerminal('\r\nTerminal error: $message\r\n');
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
// Terminal cleanup is handled server-side when service closes
super.dispose();
}

View File

@@ -16,9 +16,25 @@ bool refreshingUser = false;
class UserModel {
final RxString userName = ''.obs;
final RxString displayName = ''.obs;
final RxString avatar = ''.obs;
final RxBool isAdmin = false.obs;
final RxString networkError = ''.obs;
bool get isLogin => userName.isNotEmpty;
String get displayNameOrUserName =>
displayName.value.trim().isEmpty ? userName.value : displayName.value;
String get accountLabelWithHandle {
final username = userName.value.trim();
if (username.isEmpty) {
return '';
}
final preferred = displayName.value.trim();
if (preferred.isEmpty || preferred == username) {
return username;
}
return '$preferred (@$username)';
}
WeakReference<FFI> parent;
UserModel(this.parent) {
@@ -98,7 +114,9 @@ class UserModel {
_updateLocalUserInfo() {
final userInfo = getLocalUserInfo();
if (userInfo != null) {
userName.value = userInfo['name'];
userName.value = (userInfo['name'] ?? '').toString();
displayName.value = (userInfo['display_name'] ?? '').toString();
avatar.value = (userInfo['avatar'] ?? '').toString();
}
}
@@ -110,10 +128,14 @@ class UserModel {
await gFFI.groupModel.reset();
}
userName.value = '';
displayName.value = '';
avatar.value = '';
}
_parseAndUpdateUser(UserPayload user) {
userName.value = user.name;
displayName.value = user.displayName;
avatar.value = user.avatar;
isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
if (isWeb) {

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

@@ -475,7 +475,11 @@ class RustDeskMultiWindowManager {
final shouldSavePos = type != WindowType.Terminal || i == windows.length - 1;
if (shouldSavePos) {
debugPrint("closing multi window, type: ${type.toString()} id: $wId");
await saveWindowPosition(type, windowId: wId);
try {
await saveWindowPosition(type, windowId: wId);
} catch (e) {
debugPrint('Failed to save window position of $wId, $e');
}
}
try {
await WindowController.fromWindowId(wId).setPreventClose(false);

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}) {
@@ -1159,10 +1159,6 @@ class RustdeskImpl {
return Future.value('');
}
Future<String> mainGetPermanentPassword({dynamic hint}) {
return Future.value('');
}
Future<String> mainGetFingerprint({dynamic hint}) {
return Future.value('');
}
@@ -1346,9 +1342,9 @@ class RustdeskImpl {
throw UnimplementedError("mainUpdateTemporaryPassword");
}
Future<void> mainSetPermanentPassword(
Future<bool> mainSetPermanentPasswordWithResult(
{required String password, dynamic hint}) {
throw UnimplementedError("mainSetPermanentPassword");
throw UnimplementedError("mainSetPermanentPasswordWithResult");
}
Future<bool> mainCheckSuperUserPermission({dynamic hint}) {
@@ -1542,7 +1538,10 @@ class RustdeskImpl {
Future<void> mainAccountAuth(
{required String op, required bool rememberMe, dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
// Safari only allows auth popups while handling the original user gesture.
// Use Future.sync so the JS call runs synchronously (pre-opening the OIDC
// window) while any interop error still surfaces as a Future error.
return Future.sync(() => js.context.callMethod('setByName', [
'account_auth',
jsonEncode({'op': op, 'remember': rememberMe})
]));
@@ -1609,23 +1608,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 +1752,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 +1825,7 @@ class RustdeskImpl {
}
String mainGetBuildinOption({required String key, dynamic hint}) {
return '';
return mainGetLocalOption(key: key, hint: hint);
}
String installInstallOptions({dynamic hint}) {
@@ -1979,5 +1983,59 @@ 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;
}
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
}
void dispose() {}
}

View File

@@ -1,6 +1,6 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
project(runner LANGUAGES C CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
@@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Wayland protocol for keyboard shortcuts inhibit
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
pkg_check_modules(WAYLAND_PROTOCOLS_PKG QUIET wayland-protocols)
pkg_check_modules(WAYLAND_SCANNER_PKG QUIET wayland-scanner)
if(WAYLAND_PROTOCOLS_PKG_FOUND)
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
endif()
if(WAYLAND_SCANNER_PKG_FOUND)
pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner)
endif()
if(WAYLAND_CLIENT_FOUND AND WAYLAND_PROTOCOLS_DIR AND WAYLAND_SCANNER)
set(KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL
"${WAYLAND_PROTOCOLS_DIR}/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml")
if(EXISTS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL})
set(WAYLAND_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/wayland-protocols")
file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR})
# Generate client header
add_custom_command(
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
COMMAND ${WAYLAND_SCANNER} client-header
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
VERBATIM
)
# Generate protocol code
add_custom_command(
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
COMMAND ${WAYLAND_SCANNER} private-code
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
VERBATIM
)
set(WAYLAND_PROTOCOL_SOURCES
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
)
set(HAS_KEYBOARD_SHORTCUTS_INHIBIT TRUE)
endif()
endif()
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Define the application target. To change its name, change BINARY_NAME above,
@@ -63,7 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"wayland_shortcuts_inhibit.cc"
"bump_mouse.cc"
"bump_mouse_x11.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
${WAYLAND_PROTOCOL_SOURCES}
)
# Apply the standard set of build settings. This can be removed for applications
@@ -76,6 +129,13 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
# Wayland support for keyboard shortcuts inhibit
if(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_KEYBOARD_SHORTCUTS_INHIBIT)
target_include_directories(${BINARY_NAME} PRIVATE ${WAYLAND_GENERATED_DIR})
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
endif()
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)

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

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