Files
0740063863 Keyboard shortcuts (#14933)
* fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)

* feat(shortcuts): user-configurable keyboard shortcuts for session actions

  Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
  parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
  session actions. Bindings are stored in LocalConfig under
  `keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
  `pass_through` flags so flipping the master switch off is a hard stop.

  Wire-up summary:
  - src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
    flutter/test/fixtures/default_keyboard_shortcuts.json
  - src/keyboard.rs: shortcut intercept in process_event{,_with_session},
    feature-gated to `flutter`; runs before key swapping so users bind to
    physical keys
  - src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
    main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
  - flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
    recording dialog, shortcut display formatter, action group registry
  - flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
    flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
    shells around the shared body
  - flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
    registerSessionShortcutActions for actions with no toolbar TToggleMenu /
    TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
  - flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
    TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
    tagged entries' existing onChanged into the ShortcutModel
  - flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
    parity (default bindings, supported key vocabulary)

  Design principles applied during review:

  1. Additions are fine; modifications to original logic must be deliberate.
     Tagging an existing TToggleMenu entry with `actionId:` is an addition.
     Rewriting its onChanged to satisfy a new contract is a modification —
     and was reverted for every case where the original click behavior was
     working. Four closures were touched and then reverted (mobile View
     Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
     wheel); their shortcuts are wired via standalone closures in
     shortcut_model.dart instead.

  2. Toolbar auto-register is reserved for entries whose onChanged is
     inherently self-flipping — typically `sessionToggleOption(name)` where
     the named option is flipped in place and the input bool is unused. The
     register pass passes `!menu.value` from registration time, which is
     harmless under self-flipping but wrong for closures that consume the
     input bool directly. Tagging a non-self-flipping entry forces a closure
     rewrite; choose non-toolbar registration in that case.

  3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
     unchanged. The matcher's `enabled`-gate already guarantees no
     dispatch; the auto-register pass is left unconditional (its only effect
     is HashMap operations on a separate ShortcutModel) so mid-session
     enable works without a reconnect. The trade-off is intentional and
     documented at the top of toolbarControls.

  4. Comments stay terse. Rationale lives in one place — the doc comment of
     the helper or registration site, not duplicated at every call site.

  5. Where an existing helper needs a new optional behavior (e.g.
     `_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
     to byte-identical output for existing callers (`trailing == null`
     case → original `Expanded(Text)` layout). Verified.

  6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
     `reset_canvas` so the action ID matches its user-facing label
     ("Reset canvas") and capability flag.

  Out-of-scope but included:
  - AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
    Web target's hand-written TS client, since both are load-bearing for
    any new FFI work.
  - remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
    monitors" / "Monitor #N"), unrelated to shortcuts but kept here.

* langs

* fix(keyboard): shortcuts, harden config and callback lifecycle

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

* feat(keyboard): shortcuts, color of "Reset to defaults"

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

* feat(keyboard): shortcuts, release keys before shortcut callback

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

* feat(keyboard): shortcuts, debug web

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

* fix(keyboard): iPad, icon

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

* langs: add shortcut keys to az, gl, ur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, keep local modifiers and release keys on the right session

When a chord fired, the shortcut path reused release_remote_keys(), which
replays synthetic releases through process_event(). That cleared the local
MODIFIERS_STATE while the user still held the modifiers, so the next key of
a held chord no longer matched and leaked to the remote, and it sent the
releases to the globally current session instead of the one that typed the
chord.

The shortcut path now releases the remote keys itself: it sends through the
caller's session, and restores the modifiers it released on the remote as
still held locally. The press that fired is remembered until its release,
so its auto repeats and its release are consumed instead of reaching the
remote.

Also state the contract for unavailable actions: a bound chord always
belongs to RustDesk and is a no-op when the session has no handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, record the physical key

The recording dialog and the Web Flutter matcher named keys by
LogicalKeyboardKey, while the native matcher sees the physical key through
its USB HID usage and the Web JS matcher through KeyboardEvent.code. On
AZERTY, QWERTZ, Dvorak and similar layouts a recorded binding then did not
fire, or fired from another key.

Name keys by their physical position everywhere: physicalKeyName maps the
USB HID usage to the stored name. A new fixture pins every name to its USB
HID usage, checked on the Dart side and against rdev::usb_hid_key_from_code
on the Rust side, and a test covers AZERTY and QWERTZ events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, keep an empty binding list on re-enable

setEnabled seeded the defaults whenever the binding list was empty, so a
user who cleared every binding got all defaults back after disable and
enable. Seed only when the config has never held a bindings list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, track every fired key until its release

FIRED_KEY held a single key, so overlapping shortcut keys broke it: with the
chord held, P then C overwrote P, P's release reached the remote, and a P
repeat could fire the screenshot again. A repeat after the modifiers were
released was also treated as a new press and leaked to the remote.

Keep a set of fired keys instead. Once a press fires, every event of that
key belongs to RustDesk until its release: repeats are consumed without
dispatching, whatever the modifiers do, and the release removes the key.
Entries whose release never arrives are dropped when a session view is
entered or a session is closed, not by guessing from the modifiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, keep fired keys per session

FIRED_KEYS was process-global while dispatch is per session. Closing or
entering any session cleared the fired keys of all of them, so a key still
held in another session leaked its release to that remote, and a key fired
in one session made the same key in another session look like a repeat.

Key the fired set by session: try_dispatch resolves the session first and
only looks at that session's keys, and session_close clears only the
closing session. Entering a session view no longer clears anything: it is
a pointer enter, and a fired key may still be physically held across it.
A key whose release is missed otherwise heals itself: its next press is
consumed as a repeat and that release removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(shortcuts): preserve input state and refresh action availability

* fix(keyboard): shortcuts, keep a fired key owned across session close

Close tab is itself a shortcut, so the session that consumed the key press
is gone before the key is released. Both matchers dropped the key with the
session: the Flutter dispatcher removed its own entries in clear(), and the
Rust set was keyed by session and cleared in session_close. The release,
and the repeats while the key stayed held, then reached the next session's
remote as ordinary input.

Ownership of a fired key is physical, so it is now one set shared by every
session on each side, and closing a session no longer touches it: clear()
only drops the pending action and modifier replay, and the Rust session
state only tracks released modifiers. A key whose release is missed heals
itself on its next press. Entering view-only with the Flutter input source
still clears the Rust set, since that source stops routing key events to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, drop the Toggle Input Source action

The action switches the key capture backend between key down and key up.
Fired-key ownership lives in whichever matcher saw the press, the Flutter
dispatcher or the Rust set, and there is no handoff between them: after the
switch the repeats and the release of the held key reach the other backend,
which forwards them to the remote and, while the chord is held, matches the
action again and toggles the source back. Remove the shortcut instead of
building a handoff for one action. The toolbar radio menu is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* perf(keyboard): shortcuts, parse the config once per stored value

The Flutter matcher read the local config three times and parsed its JSON
three times on every key press in legacy mode, on Web and in view-only.
Keep one parsed snapshot keyed by the raw stored string, so a key press
costs one config read and no parsing while the config is unchanged. The
existing readers are thin views of the same snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix(keyboard): shortcuts, run focus-changing actions on key release

Close tab and tab switching move keyboard focus to another session before
the key that fired them is released. On Linux the Flutter input source
routes map-mode sessions through the Rust matcher and legacy-mode sessions
through the Dart matcher, and neither knows the other's fired keys, so
after such an action the repeats and the release of the held key reached
the next session's remote, and the key stayed recorded as fired in the
matcher that saw the press.

Routing Linux legacy mode through the Rust FFI path instead is not a drop-in
change: the raw key handler builds its event with no unicode, so Rust's
legacy mode would fall back to US key names and lose the layout-aware
characters the Dart path sends today, and it would take modifiers from the
X11 key state instead of Flutter's.

Run these three actions when the fired key is released. The matcher that
consumed the press then also sees the repeats and the release, the remote
modifiers are still released on the press, and a session that closes drops
the action it owed. Both matchers keep the same list of actions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* test(keyboard): shortcuts, pin the key-up action list on both sides

kShortcutActionsRunOnKeyUp and runs_on_release are two hand-kept lists of
the actions that run on key release. Add a shared fixture and check each
side against it, the way the key vocabulary and the default bindings are
already checked, so adding a focus-changing action on one side breaks a
test until the other side follows. The Dart test also checks that every
listed id is a configurable action.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D

* fix: prevent lost input after remote session shortcuts

Avoid synthetic Alt presses that activate the Windows system menu
and swallow the next character.

Trigger restart, switch-sides, and chat actions on key release so
focus changes cannot leave subsequent shortcut presses ignored.

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

* fix(mobile): correct Chat keyboard and input helper behavior

- Keep soft keyboard and helper hidden when Chat opens via shortcut
- Restore soft input on touch and remote focus after Chat closes
- Handle modifier releases while Chat owns focus
- Clear hidden virtual modifiers while preserving held keys and Pin behavior

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

* fix(shortcuts): prevent recording non-simultaneous key combinations

Build new captures from currently held keys instead of reusing released
letters. Preserve the last valid chord after release so it can be saved.

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

* fix(shortcuts): isolate window actions and prevent duplicate Alt presses

- Dispatch shortcut actions only to the originating session window.
- Clear local modifier state on focus loss without resending modifiers
  already released to Windows peers, preventing lost following input.
- Strengthen the regression assertion against extra Alt keydown events

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

* refact(shortcuts): empty default shortcuts for Cmd+Option+Shift+Del on macOS and iOS

Because Del may be ambiguous for new macOS users.
Cmd+Option+Shift+Backspace is "Empty Trash without confirmation" shortcut on macOS.

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

* fix: use session peer platform for Ctrl+Alt+Del events

Fix Android-to-Linux Ctrl+Alt+Del delivery by using the session's peer
platform instead of the desktop global session, which defaults to Windows

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

* fix: hide relative mouse mode shortcuts on Wayland

Separate relative mouse mode availability from screenshot support.
Filter unsupported actions and defaults while preserving saved bindings

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

* fix: align shortcut availability with platform support

- Hide unsupported mobile and web shortcut actions
- Enable mobile scaling and Android voice call shortcuts
- Use openMonitorInTheSameTab() for display switching
- Preserve cursor position when switching displays on desktop and web

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

* fix: prevent held-key shortcut recording bypass

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

* fix: preserve keyboard input after shortcut dialogs

Run native clipboard shortcuts on key release and wait for remote modifier releases before dispatching.

Release remote modifiers when mobile local UI takes focus, and document the tested iPad Bluetooth Mute shortcut limitation.

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

* fix: preserve remote key state during local shortcuts

Keep unrelated held keys pressed for session actions that retain input. Preserve key release when focus or input handling changes, and avoid synthetic Alt taps during shortcut dispatch.

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

* fix: suspend saved relative mouse shortcuts on Wayland

Filter the native active binding cache without changing stored configuration, so the binding becomes available again under X11.

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

* fix: exclude unsupported Wayland shortcuts from Flutter matching

Keep saved bindings intact while filtering the active config used by the Flutter matcher. Cover stored bindings and enable/pass-through flags in the existing shortcut tests.

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

* fix: retain forwarded key ownership across shortcut repeats

Ignore repeat presses for keys already forwarded to the peer, and release ordinary held keys before Show My Cursor enables view-only. Cover both paths with converter-level regressions.

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

* fix: release Flutter-held keys before view-only shortcuts

Track ordinary keys forwarded by the Dart matcher paths and await their releases before View Only or Show My Cursor disables input. Keep Mute key ownership and existing empty-cleanup timing unchanged.

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

* fix: filter active Flutter shortcuts by platform capabilities

Reuse the Settings action filter for saved bindings while preserving storage, default-seeding behavior and configurable Apple Ctrl+Alt+Del bindings.

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

* fix: filter native shortcut caches for mobile controllers

Suspend saved desktop-only actions on Android and iOS and hide unavailable iOS actions without changing persisted bindings. Keep native cache filtering aligned with Flutter platform capabilities.

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

* fix: share filtered shortcut bindings with the Web matcher

Pass the active platform config to JavaScript at startup and after settings changes, preserving unsupported saved bindings in local storage.

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

* fix: release Flutter-held keys before focus-changing shortcuts

Drain ordinary keys owned by the original session before run-on-key-up
actions and native clipboard callbacks. Preserve Mute holds, pending-action
cancellation and Web clipboard key-down activation.

Document the required Web matcher counterpart in the bridge:
rustdesk/rustdesk-web-js@94abe431d6

Validation: 61 native Flutter tests, 32 Chrome/HTML tests, targeted analysis
and Windows debug build.

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

* refact: remove the useless comments

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

* fix: prevent Windows menu activation from Alt-only shortcuts

Neutralize the forwarded Alt with a balanced temporary Ctrl press when a consumed local shortcut releases its only modifier in Windows Map mode. Keep physical modifier tracking and normal Alt pass-through unchanged.

Add a regression covering both Alt keys, trigger repeats and the eventual physical release. Verified with 36 native shortcut tests and real Windows menu/input checks.

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

* fix: prevent Alt menu activation in all keyboard modes

Extend Windows Alt-only shortcut neutralization to Translate and Legacy. Keep the temporary Ctrl packets in Map mode so Legacy modifier synchronization cannot release Alt before Ctrl.

Extend the existing regression across keyboard modes and decode scan, virtual and control-key events. Verify 36 native tests and 34 Windows menu replay cases.

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

* fix: initialize outgoing chat before shortcut dispatch

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

* fix: guard Windows Alt release in Flutter legacy shortcuts

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

* fix: coordinate Web shortcut dispatch and Alt modifier cleanup

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

* fix: distinguish AltGraph from ordinary Alt shortcuts

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

* fix: dispatch macOS Web shortcuts without waiting for keyup

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

* fix: support legacy peers in privacy mode shortcut

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

* fix: exclude AltGraph from Flutter shortcut matching

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

* fix: exclude GTK AltGr input from raw shortcut matching

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

* test: trim redundant keyboard shortcut coverage

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

* fix(keyboard): prevent Cmd shortcuts from opening remote Start

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

* fix(shortcuts): guard Windows menu keys after Ctrl/Cmd swapping

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

* fix(shortcuts): align recording and display routing

Reject non-Apple AltGraph captures to match runtime filtering. Honor individual-display windows for next, previous, and all-display shortcuts.

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

* fix(shortcuts): reject GTK AltGr chords in recorder

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

* fix(android): restore keyboard focus for modifier-first input

Focus the existing FlutterView when a modifier is pressed without native view focus. This restores shortcut-first input after returning to RustDesk while preserving focused inputs and normal key dispatch.

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

* fix(shortcuts): release held keys before keyboard mode changes

Drain Flutter-forwarded keys before keyboard-mode shortcut actions so releases use the original encoding. This prevents held keys from remaining down after a Web Input source 2 mode change.

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

* fix(i18n): use sentence case for shortcut labels

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

* fix(mobile): preserve touch modifiers in the input helper

Keep touch-selected modifiers visible after hiding the keyboard. Clear physical modifier releases locally in view-only mode so they do not reopen the helper.

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

* fix(web): preserve modifiers across Legacy-to-Map shortcuts

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

* fix(shortcuts): check support before registering all displays view

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:31:44 +08:00

4664 lines
156 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
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';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/cm_file_model.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_hbb/models/group_model.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/printer_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/shortcut_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/terminal_model.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';
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;
import '../common/widgets/dialog.dart';
import 'input_model.dart';
import 'platform_model.dart';
import 'package:flutter_hbb/utils/scale.dart';
import 'package:flutter_hbb/generated_bridge.dart'
if (dart.library.html) 'package:flutter_hbb/web/bridge.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
final _constSessionId = Uuid().v4obj();
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
const _restartReconnectSilentDelaySecs = 5;
class CachedPeerData {
Map<String, dynamic> updatePrivacyMode = {};
Map<String, dynamic> peerInfo = {};
// No shapes: the core keeps them for the session, and the window a tab moves to asks it
// for the one in use.
Map<String, dynamic> lastCursorId = {};
Map<String, bool> permissions = {};
bool secure = false;
bool direct = false;
String streamType = '';
CachedPeerData();
@override
String toString() {
return jsonEncode({
'updatePrivacyMode': updatePrivacyMode,
'peerInfo': peerInfo,
'lastCursorId': lastCursorId,
'permissions': permissions,
'secure': secure,
'direct': direct,
'streamType': streamType,
});
}
static CachedPeerData? fromString(String s) {
try {
final map = jsonDecode(s);
final data = CachedPeerData();
data.updatePrivacyMode = map['updatePrivacyMode'];
data.peerInfo = map['peerInfo'];
data.lastCursorId = map['lastCursorId'];
map['permissions'].forEach((key, value) {
data.permissions[key] = value;
});
data.secure = map['secure'];
data.direct = map['direct'];
data.streamType = map['streamType'];
return data;
} catch (e) {
debugPrint('Failed to parse CachedPeerData: $e');
return null;
}
}
}
class FfiModel with ChangeNotifier {
CachedPeerData cachedPeerData = CachedPeerData();
PeerInfo _pi = PeerInfo();
int? lastUserDisplay;
int? pendingMonitorRestore;
Timer? _pendingRestoreTimer;
Rect? _rect;
var _inputBlocked = false;
final _permissions = <String, bool>{};
bool? _secure;
bool? _direct;
bool _touchMode = false;
late VirtualMouseMode virtualMouseMode;
Timer? _timer;
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _androidDocumentPickerActive = false;
bool _androidDocumentPickerInterruptedConnection = false;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference<FFI> parent;
late final SessionID sessionId;
RxBool waitForImageDialogShow = true.obs;
Timer? waitForImageTimer;
RxBool waitForFirstImage = true.obs;
bool isRefreshing = false;
Timer? timerScreenshot;
Rect? get rect => _rect;
bool get isOriginalResolutionSet =>
_pi.tryGetDisplayIfNotAllDisplay()?.isOriginalResolutionSet ?? false;
bool get isVirtualDisplayResolution =>
_pi.tryGetDisplayIfNotAllDisplay()?.isVirtualDisplayResolution ?? false;
bool get isOriginalResolution =>
_pi.tryGetDisplayIfNotAllDisplay()?.isOriginalResolution ?? false;
Map<String, bool> get permissions => _permissions;
setPermissions(Map<String, bool> permissions) {
_permissions.clear();
_permissions.addAll(permissions);
}
bool? get secure => _secure;
bool? get direct => _direct;
PeerInfo get pi => _pi;
bool get inputBlocked => _inputBlocked;
bool get touchMode => _touchMode;
bool get isPeerAndroid => _pi.platform == kPeerPlatformAndroid;
bool get isPeerMobile => isPeerAndroid;
bool get isPeerLinux => _pi.platform == kPeerPlatformLinux;
bool get isPeerWindows => _pi.platform == kPeerPlatformWindows;
bool get viewOnly => _viewOnly;
bool get showMyCursor => _showMyCursor;
set inputBlocked(v) {
_inputBlocked = v;
}
FfiModel(this.parent) {
clear();
sessionId = parent.target!.sessionId;
cachedPeerData.permissions = _permissions;
virtualMouseMode = VirtualMouseMode(this);
}
Rect? globalDisplaysRect() => _getDisplaysRect(_pi.displays, true);
Rect? displaysRect() => _getDisplaysRect(_pi.getCurDisplays(), false);
Rect? _getDisplaysRect(List<Display> displays, bool useDisplayScale) {
if (displays.isEmpty) {
return null;
}
if (isPeerLinux) {
useDisplayScale = true;
}
int scale(int len, double s) {
if (useDisplayScale) {
return len.toDouble() ~/ s;
} else {
return len;
}
}
double l = displays[0].x;
double t = displays[0].y;
double r = displays[0].x + scale(displays[0].width, displays[0].scale);
double b = displays[0].y + scale(displays[0].height, displays[0].scale);
for (var display in displays.sublist(1)) {
l = min(l, display.x);
t = min(t, display.y);
r = max(r, display.x + scale(display.width, display.scale));
b = max(b, display.y + scale(display.height, display.scale));
}
return Rect.fromLTRB(l, t, r, b);
}
toggleTouchMode() {
if (!isPeerAndroid) {
_touchMode = !_touchMode;
notifyListeners();
}
}
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';
});
// Only inited at remote page
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();
}
bool get keyboard => _permissions['keyboard'] != false;
clear() {
_pi = PeerInfo();
lastUserDisplay = null;
_cancelPendingMonitorRestore();
_secure = null;
_direct = null;
_inputBlocked = false;
_timer?.cancel();
_timer = null;
_androidDocumentPickerActive = false;
_androidDocumentPickerInterruptedConnection = false;
resetRestartReconnectState();
clearPermissions();
waitForImageTimer?.cancel();
timerScreenshot?.cancel();
}
setConnectionType(
String peerId, bool secure, bool direct, String streamType) {
cachedPeerData.secure = secure;
cachedPeerData.direct = direct;
cachedPeerData.streamType = streamType;
_secure = secure;
_direct = direct;
try {
var connectionType = ConnectionTypeState.find(peerId);
connectionType.setSecure(secure);
connectionType.setDirect(direct);
connectionType.setStreamType(streamType);
} catch (e) {
//
}
}
Widget? getConnectionImageText() {
if (secure == null || direct == null) {
return null;
} else {
final icon =
'${secure == true ? 'secure' : 'insecure'}${direct == true ? '' : '_relay'}';
final iconWidget =
SvgPicture.asset('assets/$icon.svg', width: 48, height: 48);
String connectionText =
getConnectionText(secure!, direct!, cachedPeerData.streamType);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
iconWidget,
SizedBox(height: 4),
Text(
connectionText,
style: TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
],
);
}
}
clearPermissions() {
_inputBlocked = false;
_permissions.clear();
}
handleCachedPeerData(CachedPeerData data, String peerId) async {
handleMsgBox({
'type': 'success',
'title': 'Successful',
'text': kMsgboxTextWaitingForImage,
'link': '',
}, sessionId, peerId);
updatePrivacyMode(data.updatePrivacyMode, sessionId, peerId);
setConnectionType(peerId, data.secure, data.direct, data.streamType);
await handlePeerInfo(data.peerInfo, peerId, true);
if (data.lastCursorId.isNotEmpty) {
updateLastCursorId(data.lastCursorId);
handleCursorId(data.lastCursorId);
}
}
// todo: why called by two position
StreamEventHandler startEventListener(SessionID sessionId, String peerId) {
return (evt) async {
var name = evt['name'];
if (name == 'msgbox') {
handleMsgBox(evt, sessionId, peerId);
} else if (name == 'toast') {
handleToast(evt, sessionId, peerId);
} else if (name == 'set_multiple_windows_session') {
handleMultipleWindowsSession(evt, sessionId, peerId);
} else if (name == 'peer_info') {
handlePeerInfo(evt, peerId, false);
} else if (name == 'sync_peer_info') {
handleSyncPeerInfo(evt, sessionId, peerId);
} else if (name == 'sync_platform_additions') {
handlePlatformAdditions(evt, sessionId, peerId);
} else if (name == 'connection_ready') {
setConnectionType(peerId, evt['secure'] == 'true',
evt['direct'] == 'true', evt['stream_type'] ?? '');
resetRestartReconnectState();
} else if (name == 'switch_display') {
// switch display is kept for backward compatibility
handleSwitchDisplay(evt, sessionId, peerId);
} else if (name == 'cursor_id') {
updateLastCursorId(evt);
handleCursorId(evt);
} else if (name == 'cursor_position') {
await parent.target?.cursorModel.updateCursorPosition(evt, peerId);
} else if (name == 'clipboard') {
Clipboard.setData(ClipboardData(text: evt['content']));
} else if (name == 'permission') {
updatePermission(evt, peerId);
} else if (name == 'chat_client_mode') {
parent.target?.chatModel
.receive(ChatModel.clientModeID, evt['text'] ?? '');
} else if (name == 'chat_server_mode') {
parent.target?.chatModel
.receive(int.parse(evt['id'] as String), evt['text'] ?? '');
} else if (name == 'terminal_response') {
parent.target?.routeTerminalResponse(evt);
} else if (name == 'file_dir') {
parent.target?.fileModel.receiveFileDir(evt);
} else if (name == 'empty_dirs') {
parent.target?.fileModel.receiveEmptyDirs(evt);
} else if (name == 'job_progress') {
parent.target?.fileModel.jobController.tryUpdateJobProgress(evt);
} else if (name == 'job_done') {
bool? refresh =
await parent.target?.fileModel.jobController.jobDone(evt);
if (refresh == true) {
// many job done for delete directory
// todo: refresh may not work when confirm delete local directory
parent.target?.fileModel.refreshAll();
}
} else if (name == 'job_error') {
parent.target?.fileModel.handleJobError(evt);
} else if (name == 'override_file_confirm') {
parent.target?.fileModel.postOverrideFileConfirm(evt);
} else if (name == 'load_last_job') {
parent.target?.fileModel.jobController.loadLastJob(evt);
} else if (name == 'update_folder_files') {
parent.target?.fileModel.jobController.updateFolderFiles(evt);
} else if (name == 'add_connection') {
parent.target?.serverModel.addConnection(evt);
} else if (name == 'on_client_remove') {
parent.target?.serverModel.onClientRemove(evt);
} else if (name == 'update_quality_status') {
parent.target?.qualityMonitorModel.updateQualityStatus(evt);
} else if (name == 'update_block_input_state') {
updateBlockInputState(evt, peerId);
} else if (name == 'update_privacy_mode') {
updatePrivacyMode(evt, sessionId, peerId);
} else if (name == 'show_elevation') {
final show = evt['show'].toString() == 'true';
parent.target?.serverModel.setShowElevation(show);
} else if (name == 'cancel_msgbox') {
cancelMsgBox(evt, sessionId);
} else if (name == 'switch_back') {
final peer_id = evt['peer_id'].toString();
await bind.sessionSwitchSides(sessionId: sessionId);
closeConnection(id: peer_id);
} else if (name == 'portable_service_running') {
_handlePortableServiceRunning(peerId, evt);
} else if (name == 'on_url_scheme_received') {
// currently comes from "_url" ipc of mac and dbus of linux
onUrlSchemeReceived(evt);
} else if (name == 'on_voice_call_waiting') {
// Waiting for the response from the peer.
parent.target?.chatModel.onVoiceCallWaiting();
} else if (name == 'on_voice_call_started') {
// Voice call is connected.
parent.target?.chatModel.onVoiceCallStarted();
} else if (name == 'on_voice_call_closed') {
// Voice call is closed with reason.
final reason = evt['reason'].toString();
parent.target?.chatModel.onVoiceCallClosed(reason);
} else if (name == 'on_voice_call_incoming') {
// Voice call is requested by the peer.
parent.target?.chatModel.onVoiceCallIncoming();
} else if (name == 'update_voice_call_state') {
parent.target?.serverModel.updateVoiceCallState(evt);
} else if (name == 'fingerprint') {
FingerprintState.find(peerId).value = evt['fingerprint'] ?? '';
} else if (name == "sync_peer_hash_password_to_personal_ab") {
if (desktopType == DesktopType.main || isWeb || isMobile) {
final id = evt['id'];
final hash = evt['hash'];
if (id != null && hash != null) {
gFFI.abModel
.changePersonalHashPassword(id.toString(), hash.toString());
}
}
} else if (name == "cm_file_transfer_log") {
if (isDesktop) {
gFFI.cmFileModel.onFileTransferLog(evt);
}
} else if (name == 'sync_peer_option') {
_handleSyncPeerOption(evt, peerId);
} else if (name == 'follow_current_display') {
handleFollowCurrentDisplay(evt, sessionId, peerId);
} else if (name == 'use_texture_render') {
_handleUseTextureRender(evt, sessionId, peerId);
} else if (name == "selected_files") {
if (isWeb) {
parent.target?.fileModel.onSelectedFiles(evt);
}
} else if (name == "send_emptry_dirs") {
if (isWeb) {
parent.target?.fileModel.sendEmptyDirs(evt);
}
} else if (name == "record_status") {
if (desktopType == DesktopType.remote ||
desktopType == DesktopType.viewCamera ||
isMobile) {
parent.target?.recordingModel.updateStatus(evt['start'] == 'true');
}
} else if (name == "printer_request") {
_handlePrinterRequest(evt, sessionId, peerId);
} else 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 if (name == kShortcutEventName) {
final action = evt['action'];
if (action is String) {
parent.target?.shortcutModel.onTriggered(action);
}
} else {
debugPrint('Event is not handled in the fixed branch: $name');
}
};
}
_handleScreenshot(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
timerScreenshot?.cancel();
timerScreenshot = null;
final msg = evt['msg'] ?? '';
final msgBoxType = 'custom-nook-nocancel-hasclose';
final msgBoxTitle = 'Take screenshot';
final dialogManager = parent.target!.dialogManager;
if (msg.isNotEmpty) {
msgBox(sessionId, msgBoxType, msgBoxTitle, msg, '', dialogManager);
} else {
final msgBoxText = 'screenshot-action-tip';
close() {
dialogManager.dismissAll();
}
saveAs() {
close();
Future.delayed(Duration.zero, () async {
final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000;
String? outputFile = await FilePicker.platform.saveFile(
dialogTitle: '${translate('Save as')}...',
fileName: 'screenshot_$ts.png',
allowedExtensions: ['png'],
type: FileType.custom,
);
if (outputFile == null) {
bind.sessionHandleScreenshot(sessionId: sessionId, action: '2');
} else {
final res = await bind.sessionHandleScreenshot(
sessionId: sessionId, action: '0:$outputFile');
if (res.isNotEmpty) {
msgBox(sessionId, 'custom-nook-nocancel-hasclose-error',
'Take screenshot', res, '', dialogManager);
}
}
});
}
copyToClipboard() {
bind.sessionHandleScreenshot(sessionId: sessionId, action: '1');
close();
}
cancel() {
bind.sessionHandleScreenshot(sessionId: sessionId, action: '2');
close();
}
final List<Widget> buttons = [
dialogButton('${translate('Save as')}...', onPressed: saveAs),
dialogButton('Copy to clipboard', onPressed: copyToClipboard),
dialogButton('Cancel', onPressed: cancel),
];
dialogManager.dismissAll();
dialogManager.show(
(setState, close, context) => CustomAlertDialog(
title: null,
content: SelectionArea(
child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)),
actions: buttons,
),
tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle',
);
}
}
_handlePrinterRequest(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
final id = evt['id'];
final path = evt['path'];
final dialogManager = parent.target!.dialogManager;
dialogManager.show((setState, close, context) {
PrinterOptions printerOptions = PrinterOptions.load();
final saveSettings = mainGetLocalBoolOptionSync(kKeyPrinterSave).obs;
final dontShowAgain = false.obs;
final Rx<String> selectedPrinterName = printerOptions.printerName.obs;
final printerNames = printerOptions.printerNames;
final defaultOrSelectedGroupValue =
(printerOptions.action == kValuePrinterIncomingJobDismiss
? kValuePrinterIncomingJobDefault
: printerOptions.action)
.obs;
onRatioChanged(String? value) {
defaultOrSelectedGroupValue.value =
value ?? kValuePrinterIncomingJobDefault;
}
onSubmit() {
final printerName = defaultOrSelectedGroupValue.isEmpty
? ''
: selectedPrinterName.value;
bind.sessionPrinterResponse(
sessionId: sessionId, id: id, path: path, printerName: printerName);
if (saveSettings.value || dontShowAgain.value) {
bind.mainSetLocalOption(key: kKeyPrinterSelected, value: printerName);
bind.mainSetLocalOption(
key: kKeyPrinterIncomingJobAction,
value: defaultOrSelectedGroupValue.value);
}
if (dontShowAgain.value) {
mainSetLocalBoolOption(kKeyPrinterAllowAutoPrint, true);
}
close();
}
onCancel() {
if (dontShowAgain.value) {
bind.mainSetLocalOption(
key: kKeyPrinterIncomingJobAction,
value: kValuePrinterIncomingJobDismiss);
}
close();
}
final printerItemHeight = 30.0;
final selectionAreaHeight =
printerItemHeight * min(8.0, max(printerNames.length, 3.0));
final content = Column(
children: [
Text(translate('print-incoming-job-confirm-tip')),
Row(
children: [
Obx(() => Radio<String>(
value: kValuePrinterIncomingJobDefault,
groupValue: defaultOrSelectedGroupValue.value,
onChanged: onRatioChanged)),
GestureDetector(
child: Text(translate('use-the-default-printer-tip')),
onTap: () => onRatioChanged(kValuePrinterIncomingJobDefault)),
],
),
Column(
children: [
Row(children: [
Obx(() => Radio<String>(
value: kValuePrinterIncomingJobSelected,
groupValue: defaultOrSelectedGroupValue.value,
onChanged: onRatioChanged)),
GestureDetector(
child: Text(translate('use-the-selected-printer-tip')),
onTap: () =>
onRatioChanged(kValuePrinterIncomingJobSelected)),
]),
SizedBox(
height: selectionAreaHeight,
width: 500,
child: ListView.builder(
itemBuilder: (context, index) {
return Obx(() => GestureDetector(
child: Container(
decoration: BoxDecoration(
color: selectedPrinterName.value ==
printerNames[index]
? (defaultOrSelectedGroupValue.value ==
kValuePrinterIncomingJobSelected
? MyTheme.button
: MyTheme.button.withOpacity(0.5))
: Theme.of(context).cardColor,
borderRadius: BorderRadius.all(
Radius.circular(5.0),
),
),
key: ValueKey(printerNames[index]),
height: printerItemHeight,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
printerNames[index],
style: TextStyle(fontSize: 14),
),
),
),
),
onTap: defaultOrSelectedGroupValue.value ==
kValuePrinterIncomingJobSelected
? () {
selectedPrinterName.value =
printerNames[index];
}
: null,
));
},
itemCount: printerNames.length),
),
],
),
Row(
children: [
Obx(() => Checkbox(
value: saveSettings.value,
onChanged: (value) {
if (value != null) {
saveSettings.value = value;
mainSetLocalBoolOption(kKeyPrinterSave, value);
}
})),
GestureDetector(
child: Text(translate('save-settings-tip')),
onTap: () {
saveSettings.value = !saveSettings.value;
mainSetLocalBoolOption(kKeyPrinterSave, saveSettings.value);
}),
],
),
Row(
children: [
Obx(() => Checkbox(
value: dontShowAgain.value,
onChanged: (value) {
if (value != null) {
dontShowAgain.value = value;
}
})),
GestureDetector(
child: Text(translate('dont-show-again-tip')),
onTap: () {
dontShowAgain.value = !dontShowAgain.value;
}),
],
),
],
);
return CustomAlertDialog(
title: Text(translate('Incoming Print Job')),
content: content,
actions: [
dialogButton('OK', onPressed: onSubmit),
dialogButton('Cancel', onPressed: onCancel),
],
onSubmit: onSubmit,
onCancel: onCancel,
);
});
}
_handleUseTextureRender(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
waitForFirstImage.value = true;
isRefreshing = true;
showConnectedWaitingForImage(parent.target!.dialogManager, sessionId,
'success', 'Successful', kMsgboxTextWaitingForImage);
}
_handleSyncPeerOption(Map<String, dynamic> evt, String peer) {
final k = evt['k'];
final v = evt['v'];
if (k == kOptionToggleViewOnly) {
setViewOnly(peer, v as bool);
} else if (k == 'keyboard_mode') {
parent.target?.inputModel.updateKeyboardMode();
} else if (k == 'input_source') {
stateGlobal.getInputSource(force: true);
}
}
onUrlSchemeReceived(Map<String, dynamic> evt) {
final url = evt['url'].toString().trim();
if (url.startsWith(bind.mainUriPrefixSync()) &&
handleUriLink(uriString: url)) {
return;
}
switch (url) {
case kUrlActionClose:
debugPrint("closing all instances");
Future.microtask(() async {
await rustDeskWinManager.closeAllSubWindows();
windowManager.close();
});
break;
default:
windowOnTop(null);
break;
}
}
/// Bind the event listener to receive events from the Rust core.
updateEventListener(SessionID sessionId, String peerId) {
platformFFI.setEventCallback(startEventListener(sessionId, peerId));
}
_handlePortableServiceRunning(String peerId, Map<String, dynamic> evt) {
final running = evt['running'] == 'true';
parent.target?.elevationModel.onPortableServiceRunning(running);
}
handleAliasChanged(Map<String, dynamic> evt) {
if (!(isDesktop || isWebDesktop)) return;
final String peerId = evt['id'];
final String alias = evt['alias'];
String label = getDesktopTabLabel(peerId, alias);
final rxTabLabel = PeerStringOption.find(evt['id'], 'tabLabel');
if (rxTabLabel.value != label) {
rxTabLabel.value = label;
}
}
Future<void> updateCurDisplay(SessionID sessionId,
{updateCursorPos = false}) async {
final newRect = displaysRect();
if (newRect == null) {
return;
}
if (newRect != _rect) {
if (newRect.left != _rect?.left || newRect.top != _rect?.top) {
parent.target?.cursorModel.updateDisplayOrigin(
newRect.left, newRect.top,
updateCursorPos: updateCursorPos);
}
_rect = newRect;
// 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();
}
}
}
handleSwitchDisplay(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
final display = int.parse(evt['display']);
if (_pi.currentDisplay != kAllDisplayValue) {
if (bind.peerGetSessionsCount(
id: peerId, connType: parent.target!.connType.index) >
1) {
if (display != _pi.currentDisplay) {
return;
}
}
if (!_pi.isSupportMultiUiSession) {
_pi.currentDisplay = display;
}
// If `isSupportMultiUiSession` is true, the switch display message should not be used to update current display.
// It is only used to update the display info.
}
var newDisplay = Display();
newDisplay.x = double.tryParse(evt['x']) ?? newDisplay.x;
newDisplay.y = double.tryParse(evt['y']) ?? newDisplay.y;
newDisplay.width = int.tryParse(evt['width']) ?? newDisplay.width;
newDisplay.height = int.tryParse(evt['height']) ?? newDisplay.height;
newDisplay.cursorEmbedded = int.tryParse(evt['cursor_embedded']) == 1;
newDisplay.originalWidth = int.tryParse(
evt['original_width'] ?? kInvalidResolutionValue.toString()) ??
kInvalidResolutionValue;
newDisplay.originalHeight = int.tryParse(
evt['original_height'] ?? kInvalidResolutionValue.toString()) ??
kInvalidResolutionValue;
newDisplay._scale = _pi.scaleOfDisplay(display);
_pi.displays[display] = newDisplay;
if (!_pi.isSupportMultiUiSession || _pi.currentDisplay == display) {
updateCurDisplay(sessionId);
}
if (!_pi.isSupportMultiUiSession) {
try {
CurrentDisplayState.find(peerId).value = display;
} catch (e) {
//
}
}
if (!_pi.isSupportMultiUiSession || _pi.currentDisplay == display) {
handleResolutions(peerId, evt['resolutions']);
}
notifyListeners();
}
cancelMsgBox(Map<String, dynamic> evt, SessionID sessionId) {
if (parent.target == null) return;
final dialogManager = parent.target!.dialogManager;
final tag = '$sessionId-${evt['tag']}';
dialogManager.dismissByTag(tag);
}
handleMultipleWindowsSession(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
if (parent.target == null) return;
final dialogManager = parent.target!.dialogManager;
final sessions = evt['windows_sessions'];
final title = translate('Multiple Windows sessions found');
final text = translate('Please select the session you want to connect to');
final type = "";
showWindowsSessionsDialog(
type, title, text, dialogManager, sessionId, peerId, sessions);
}
/// Handle the message box event based on [evt] and [id].
handleMsgBox(Map<String, dynamic> evt, SessionID sessionId, String peerId) {
if (parent.target == null) return;
final dialogManager = parent.target!.dialogManager;
final type = evt['type'];
final title = evt['title'];
final text = evt['text'];
final link = evt['link'];
// The peer-gone detector reconnects under `restarting-show` rather than an error title, so
// it needs naming here too. By its own title, not the type: an explicitly restarted remote
// device reaches the same type from a path this change does not touch.
if (isAndroid &&
_androidDocumentPickerActive &&
(title == 'Connection Error' ||
(type == 'restarting-show' && title == 'Connecting...'))) {
_androidDocumentPickerInterruptedConnection = true;
return;
}
// 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') {
enter2FaDialog(sessionId, dialogManager);
} else if (type == 'input-password') {
enterPasswordDialog(sessionId, dialogManager);
} else if (type == 'terminal-admin-login') {
enterUserLoginDialog(
sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'terminal-admin-login-password') {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'restarting') {
// Treat restart messages as reconnect control events. Rust still sends
// title/text for legacy UI and translation reuse; Flutter keeps the last
// frame briefly, then shows the Connecting overlay.
if (_restartReconnectDelayTimer == null) {
parent.target?.inputModel.setRelativeMouseMode(false);
_cancelPendingMonitorRestore();
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
clearPermissions();
// Retry once more after the silent window so restart reconnect attempts
// are spaced by the empirical short cadence instead of only updating UI.
_restartReconnectDelayTimer =
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
_restartReconnectDelayTimer = null;
if (parent.target?.closed == true) {
return;
}
reconnect(dialogManager, sessionId, false);
});
}
} else if (type == 'restarting-show') {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
reconnect(dialogManager, sessionId, false);
} else if (type == 'wait-remote-accept-nook') {
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
showOnBlockDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'wait-uac') {
showWaitUacDialog(sessionId, dialogManager, type);
} else if (type == 'elevation-error') {
showElevationError(sessionId, type, title, text, dialogManager);
} else if (type == 'relay-hint' || type == 'relay-hint2') {
showRelayHintDialog(sessionId, type, title, text, dialogManager, peerId);
} else if (text == kMsgboxTextWaitingForImage) {
showConnectedWaitingForImage(dialogManager, sessionId, type, title, text);
} else if (title == 'Privacy mode') {
final hasRetry = evt['hasRetry'] == 'true';
showPrivacyFailedDialog(
sessionId, type, title, text, link, hasRetry, dialogManager);
} else {
var hasRetry = evt['hasRetry'] == 'true';
if (!hasRetry) {
hasRetry = shouldAutoRetryOnOffline(type, title, text);
}
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
}
}
void resetRestartReconnectState() {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
}
void beginAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = true;
_androidDocumentPickerInterruptedConnection = false;
}
void endAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = false;
if (!_androidDocumentPickerInterruptedConnection ||
parent.target?.closed == true) {
return;
}
_androidDocumentPickerInterruptedConnection = false;
reconnect(parent.target!.dialogManager, sessionId, false);
}
/// 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'] ?? '';
final durMsc = evt['dur_msec'] ?? 2000;
final duration = Duration(milliseconds: durMsc);
if ((text).isEmpty) {
BotToast.showLoading(
duration: duration,
clickClose: true,
allowClick: true,
);
} else {
if (type.contains('error')) {
BotToast.showText(
contentColor: Colors.red,
text: translate(text),
duration: duration,
clickClose: true,
onlyOne: true,
);
} else {
BotToast.showText(
text: translate(text),
duration: duration,
clickClose: true,
onlyOne: true,
);
}
}
}
/// 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}) 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), () {
reconnect(dialogManager, sessionId, false);
});
_reconnects *= 2;
} else {
_reconnects = 1;
_offlineReconnectStartTime = null;
}
}
void _cancelPendingMonitorRestore() {
_pendingRestoreTimer?.cancel();
_pendingRestoreTimer = null;
pendingMonitorRestore = null;
}
void cancelPendingRestoreTimer() {
_pendingRestoreTimer?.cancel();
_pendingRestoreTimer = 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);
_cancelPendingMonitorRestore();
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions();
dialogManager.dismissAll();
dialogManager.showLoading(translate('Connecting...'),
onCancel: closeConnection);
}
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();
close();
}
final style =
ElevatedButton.styleFrom(backgroundColor: Colors.green[700]);
return CustomAlertDialog(
title: null,
content: msgboxContent(type, title, text2),
actions: [
dialogButton('Close', onPressed: onClose, isOutline: true),
if (type == 'relay-hint')
dialogButton('Connect via relay',
onPressed: () => reconnect(dialogManager, sessionId, true),
buttonStyle: style,
isOutline: true),
dialogButton('Retry',
onPressed: () => reconnect(dialogManager, sessionId, false)),
if (type == 'relay-hint2')
dialogButton('Connect via relay',
onPressed: () => reconnect(dialogManager, sessionId, true),
buttonStyle: style),
],
onCancel: onClose,
);
});
}
void showConnectedWaitingForImage(OverlayDialogManager dialogManager,
SessionID sessionId, String type, String title, String text) {
onClose() {
closeConnection();
}
if (waitForFirstImage.isFalse) return;
dialogManager.show(
(setState, close, context) => CustomAlertDialog(
title: null,
content: SelectionArea(child: msgboxContent(type, title, text)),
actions: [
dialogButton("Cancel", onPressed: onClose, isOutline: true)
],
onCancel: onClose),
tag: '$sessionId-waiting-for-image',
);
waitForImageDialogShow.value = true;
waitForImageTimer = Timer(Duration(milliseconds: 1500), () {
if (waitForFirstImage.isTrue && !isRefreshing) {
bind.sessionInputOsPassword(sessionId: sessionId, value: '');
}
});
bind.sessionOnWaitingForImageDialogShow(sessionId: sessionId);
}
void showPrivacyFailedDialog(
SessionID sessionId,
String type,
String title,
String text,
String link,
bool hasRetry,
OverlayDialogManager dialogManager) {
// There are display changes on the remote side,
// which will cause some messages to refresh the canvas and dismiss dialogs.
// So we add a delay here to ensure the dialog is displayed.
Future.delayed(Duration(milliseconds: 3000), () {
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
});
}
_updateSessionWidthHeight(SessionID sessionId) {
if (_rect == null) return;
if (_rect!.width <= 0 || _rect!.height <= 0) {
debugPrintStack(
label: 'invalid display size (${_rect!.width},${_rect!.height})');
} else {
final displays = _pi.getCurDisplays();
if (displays.length == 1) {
bind.sessionSetSize(
sessionId: sessionId,
display:
pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
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,
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;
_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.
cachedPeerData.peerInfo = {...evt};
// Do not cache resolutions, because a new display connection have different resolutions.
cachedPeerData.peerInfo.remove('resolutions');
// Recent peer is updated by handle_peer_info(ui_session_interface.rs) --> handle_peer_info(client.rs) --> save_config(client.rs)
bind.mainLoadRecentPeers();
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'];
_pi.hostname = evt['hostname'];
_pi.platform = evt['platform'];
_pi.sasEnabled = evt['sas_enabled'] == 'true';
final currentDisplay = int.parse(evt['current_display']);
if (_pi.primaryDisplay == kInvalidDisplayIndex) {
_pi.primaryDisplay = currentDisplay;
}
if (bind.peerGetSessionsCount(
id: peerId, connType: parent.target!.connType.index) <=
1) {
_pi.currentDisplay = currentDisplay;
}
try {
CurrentDisplayState.find(peerId).value = _pi.currentDisplay;
} catch (e) {
//
}
final connType = parent.target?.connType;
if (isPeerAndroid) {
_touchMode = true;
} else {
// `kOptionTouchMode` is originally peer option, but it is moved to local option later.
// We check local option first, if not set, then check peer option.
// Because if local option is not empty:
// 1. User has set the touch mode explicitly.
// 2. The advanced option (custom client) is set.
// Then we choose to use the local option.
final optLocal = bind.mainGetLocalOption(key: kOptionTouchMode);
if (optLocal != '') {
_touchMode = optLocal == 'Y';
} else {
final optSession = await bind.sessionGetOption(
sessionId: sessionId, arg: kOptionTouchMode);
_touchMode = optSession != '';
}
}
if (isMobile) {
virtualMouseMode.loadOptions();
}
if (connType == ConnType.fileTransfer) {
parent.target?.fileModel.onReady();
} else if (connType == ConnType.terminal) {
// Call onReady on all registered terminal models
final models = parent.target?._terminalModels.values ?? [];
for (final model in models) {
model.onReady();
}
} else if (connType == ConnType.defaultConn ||
connType == ConnType.viewCamera) {
List<Display> newDisplays = [];
List<dynamic> displays = json.decode(evt['displays']);
for (int i = 0; i < displays.length; ++i) {
newDisplays.add(evtToDisplay(displays[i]));
}
_pi.displays.value = newDisplays;
_pi.displaysCount.value = _pi.displays.length;
if (_pi.currentDisplay < _pi.displays.length) {
// now replaced to _updateCurDisplay
updateCurDisplay(sessionId);
}
// After reconnecting, restore the last selected monitor once the canvas is ready.
// Switching earlier can offset the view if the monitor sizes differ.
// New monitor windows keep their selection in currentDisplay.
final last = lastUserDisplay ?? _pi.currentDisplay;
pendingMonitorRestore = (!isCache &&
last != currentDisplay &&
bind.sessionGetUseAllMyDisplaysForTheRemoteSession(
sessionId: sessionId) !=
'Y' &&
((last == kAllDisplayValue && _pi.displays.isNotEmpty) ||
(last >= 0 && last < _pi.displays.length)))
? last
: null;
// Fallback if the first image event never reaches this tab (multi-UI).
_pendingRestoreTimer?.cancel();
if (pendingMonitorRestore != null) {
_pendingRestoreTimer = Timer(const Duration(milliseconds: 1500),
() => parent.target?._applyPendingMonitorRestore());
}
if (displays.isNotEmpty) {
_reconnects = 1;
_offlineReconnectStartTime = null;
resetRestartReconnectState();
waitForFirstImage.value = true;
isRefreshing = false;
}
Map<String, dynamic> features = json.decode(evt['features']);
_pi.features.privacyMode = features['privacy_mode'] == true;
if (!isCache) {
handleResolutions(peerId, evt["resolutions"]);
}
parent.target?.elevationModel.onPeerInfo(_pi);
}
if (connType == ConnType.defaultConn) {
setViewOnly(
peerId,
bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionToggleViewOnly));
setShowMyCursor(bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionToggleShowMyCursor));
}
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
final platformAdditions = evt['platform_additions'];
if (platformAdditions != null && platformAdditions != '') {
try {
_pi.platformAdditions = json.decode(platformAdditions);
} catch (e) {
debugPrint('Failed to decode platformAdditions $e');
}
}
}
_pi.isSet.value = true;
stateGlobal.resetLastResolutionGroupValues(peerId);
if (isDesktop || isWebDesktop) {
// 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();
if (!isCache) {
tryUseAllMyDisplaysForTheRemoteSession(peerId);
}
}
checkDesktopKeyboardMode() async {
if (isInputSourceFlutter) {
// Local side, flutter keyboard input source
// Currently only map mode is supported, legacy mode is used for compatibility.
for (final mode in [kKeyMapMode, kKeyLegacyMode]) {
if (bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: mode)) {
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
break;
}
}
} else {
final curMode = await bind.sessionGetKeyboardMode(sessionId: sessionId);
if (curMode != null) {
if (bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: curMode)) {
return;
}
}
// If current keyboard mode is not supported, change to another one.
for (final mode in [kKeyMapMode, kKeyTranslateMode, kKeyLegacyMode]) {
if (bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: mode)) {
bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
break;
}
}
}
}
tryUseAllMyDisplaysForTheRemoteSession(String peerId) async {
if (bind.sessionGetUseAllMyDisplaysForTheRemoteSession(
sessionId: sessionId) !=
'Y') {
return;
}
if (!_pi.isSupportMultiDisplay || _pi.displays.length <= 1) {
return;
}
final screenRectList = await getScreenRectList();
if (screenRectList.length <= 1) {
return;
}
// to-do: peer currentDisplay is the primary display, but the primary display may not be the first display.
// local primary display also may not be the first display.
//
// 0 is assumed to be the primary display here, for now.
// move to the first display and set fullscreen
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([0]),
);
_pi.currentDisplay = 0;
try {
CurrentDisplayState.find(peerId).value = _pi.currentDisplay;
} catch (e) {
//
}
await tryMoveToScreenAndSetFullscreen(screenRectList[0]);
final length = _pi.displays.length < screenRectList.length
? _pi.displays.length
: screenRectList.length;
for (var i = 1; i < length; i++) {
openMonitorInNewTabOrWindow(i, peerId, _pi,
screenRect: screenRectList[i]);
}
}
tryShowAndroidActionsOverlay({int delayMSecs = 10}) {
if (isPeerAndroid) {
if (parent.target?.connType == ConnType.defaultConn &&
parent.target != null &&
parent.target!.ffiModel.permissions['keyboard'] != false) {
Timer(Duration(milliseconds: delayMSecs), () {
if (parent.target!.dialogManager.mobileActionsOverlayVisible.isTrue) {
parent.target!.dialogManager
.showMobileActionsOverlay(ffi: parent.target!);
}
});
}
}
}
handleResolutions(String id, dynamic resolutions) {
try {
final resolutionsObj = json.decode(resolutions as String);
late List<dynamic> dynamicArray;
if (resolutionsObj is Map) {
// The web version
dynamicArray = (resolutionsObj as Map<String, dynamic>)['resolutions']
as List<dynamic>;
} else {
// The rust version
dynamicArray = resolutionsObj as List<dynamic>;
}
List<Resolution> arr = List.empty(growable: true);
for (int i = 0; i < dynamicArray.length; i++) {
var width = dynamicArray[i]["width"];
var height = dynamicArray[i]["height"];
if (width is int && width > 0 && height is int && height > 0) {
arr.add(Resolution(width, height));
}
}
arr.sort((a, b) {
if (b.width != a.width) {
return b.width - a.width;
} else {
return b.height - a.height;
}
});
_pi.resolutions = arr;
} catch (e) {
debugPrint("Failed to parse resolutions:$e");
}
}
Display evtToDisplay(Map<String, dynamic> evt) {
var d = Display();
d.x = evt['x']?.toDouble() ?? d.x;
d.y = evt['y']?.toDouble() ?? d.y;
d.width = evt['width'] ?? d.width;
d.height = evt['height'] ?? d.height;
d.cursorEmbedded = evt['cursor_embedded'] == 1;
d.originalWidth = evt['original_width'] ?? kInvalidResolutionValue;
d.originalHeight = evt['original_height'] ?? kInvalidResolutionValue;
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;
}
updateLastCursorId(Map<String, dynamic> evt) {
// int.parse(evt['id']) may cause FormatException
// Unhandled Exception: FormatException: Positive input exceeds the limit of integer 18446744071749110741
parent.target?.cursorModel.id = evt['id'];
}
handleCursorId(Map<String, dynamic> evt) {
cachedPeerData.lastCursorId = evt;
parent.target?.cursorModel.updateCursorId(evt);
}
/// A shape arriving is the shape in use, as a cursor_id is.
handleCursorData(String id, int hotx, int hoty, int width, int height,
Uint8List colors) async {
// The replay selects this last, whatever the order the shapes are replayed in.
cachedPeerData.lastCursorId = {'id': id};
parent.target?.cursorModel.id = id;
await parent.target?.cursorModel
.updateCursorData(id, hotx, hoty, width, height, colors);
}
/// Handle the peer info synchronization event based on [evt].
handleSyncPeerInfo(
Map<String, dynamic> evt, SessionID sessionId, String peerId) async {
if (evt['displays'] != null) {
cachedPeerData.peerInfo['displays'] = evt['displays'];
List<dynamic> displays = json.decode(evt['displays']);
List<Display> newDisplays = [];
for (int i = 0; i < displays.length; ++i) {
newDisplays.add(evtToDisplay(displays[i]));
}
_pi.displays.value = newDisplays;
_pi.displaysCount.value = _pi.displays.length;
if (_pi.currentDisplay == kAllDisplayValue) {
updateCurDisplay(sessionId);
// to-do: What if the displays are changed?
} else {
if (_pi.currentDisplay >= 0 &&
_pi.currentDisplay < _pi.displays.length) {
updateCurDisplay(sessionId);
} else {
if (_pi.displays.isNotEmpty) {
// Notify to switch display
msgBox(sessionId, 'custom-nook-nocancel-hasclose-info', 'Prompt',
'display_is_plugged_out_msg', '', parent.target!.dialogManager);
final isPeerPrimaryDisplayValid =
pi.primaryDisplay == kInvalidDisplayIndex ||
pi.primaryDisplay >= pi.displays.length;
final newDisplay =
isPeerPrimaryDisplayValid ? 0 : pi.primaryDisplay;
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([newDisplay]),
);
if (_pi.isSupportMultiUiSession) {
// If the peer supports multi-ui-session, no switch display message will be send back.
// We need to update the display manually.
switchToNewDisplay(newDisplay, sessionId, peerId);
}
} else {
msgBox(sessionId, 'nocancel-error', 'Prompt', 'No Displays', '',
parent.target!.dialogManager);
}
}
}
}
parent.target!.canvasModel
.tryUpdateScrollStyle(Duration(milliseconds: 300), null);
notifyListeners();
}
handlePlatformAdditions(
Map<String, dynamic> evt, SessionID sessionId, String peerId) async {
final updateData = evt['platform_additions'] as String?;
if (updateData == null) {
return;
}
if (updateData.isEmpty) {
_pi.platformAdditions.remove(kPlatformAdditionsRustDeskVirtualDisplays);
_pi.platformAdditions.remove(kPlatformAdditionsAmyuniVirtualDisplays);
} else {
try {
final updateJson = json.decode(updateData) as Map<String, dynamic>;
for (final key in updateJson.keys) {
_pi.platformAdditions[key] = updateJson[key];
}
if (!updateJson
.containsKey(kPlatformAdditionsRustDeskVirtualDisplays)) {
_pi.platformAdditions
.remove(kPlatformAdditionsRustDeskVirtualDisplays);
}
if (!updateJson.containsKey(kPlatformAdditionsAmyuniVirtualDisplays)) {
_pi.platformAdditions.remove(kPlatformAdditionsAmyuniVirtualDisplays);
}
} catch (e) {
debugPrint('Failed to decode platformAdditions $e');
}
}
cachedPeerData.peerInfo['platform_additions'] =
json.encode(_pi.platformAdditions);
}
handleFollowCurrentDisplay(
Map<String, dynamic> evt, SessionID sessionId, String peerId) async {
if (evt['display_idx'] != null) {
if (pi.currentDisplay == kAllDisplayValue) {
return;
}
_pi.currentDisplay = int.parse(evt['display_idx']);
try {
CurrentDisplayState.find(peerId).value = _pi.currentDisplay;
} catch (e) {
//
}
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([_pi.currentDisplay]),
);
}
notifyListeners();
}
// Directly switch to the new display without waiting for the response.
switchToNewDisplay(int display, SessionID sessionId, String peerId,
{bool updateCursorPos = false}) {
// no need to wait for the response
pi.currentDisplay = display;
updateCurDisplay(sessionId, updateCursorPos: updateCursorPos);
try {
CurrentDisplayState.find(peerId).value = display;
} catch (e) {
//
}
}
updateBlockInputState(Map<String, dynamic> evt, String peerId) {
_inputBlocked = evt['input_state'] == 'on';
notifyListeners();
try {
BlockInputState.find(peerId).value = evt['input_state'] == 'on';
} catch (e) {
//
}
}
updatePrivacyMode(
Map<String, dynamic> evt, SessionID sessionId, String peerId) async {
notifyListeners();
try {
final isOn = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'privacy-mode');
if (isOn) {
var privacyModeImpl = await bind.sessionGetOption(
sessionId: sessionId, arg: 'privacy-mode-impl-key');
// For compatibility, version < 1.2.4, the default value is 'privacy_mode_impl_mag'.
final initDefaultPrivacyMode = 'privacy_mode_impl_mag';
PrivacyModeState.find(peerId).value =
privacyModeImpl ?? initDefaultPrivacyMode;
} else {
PrivacyModeState.find(peerId).value = '';
}
} catch (e) {
//
}
}
void setViewOnly(String id, bool value) {
if (versionCmp(_pi.version, '1.2.0') < 0) return;
// tmp fix for https://github.com/rustdesk/rustdesk/pull/3706#issuecomment-1481242389
// because below rx not used in mobile version, so not initialized, below code will cause crash
// current our flutter code quality is fucking shit now. !!!!!!!!!!!!!!!!
try {
if (value) {
ShowRemoteCursorState.find(id).value = value;
} else {
ShowRemoteCursorState.find(id).value = bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: 'show-remote-cursor');
}
} catch (e) {
//
}
if (_viewOnly != value) {
_viewOnly = value;
notifyListeners();
}
}
void setShowMyCursor(bool value) {
if (_showMyCursor != value) {
_showMyCursor = value;
notifyListeners();
}
}
}
class VirtualMouseMode with ChangeNotifier {
bool _showVirtualMouse = false;
double _virtualMouseScale = 1.0;
bool _showVirtualJoystick = false;
bool get showVirtualMouse => _showVirtualMouse;
double get virtualMouseScale => _virtualMouseScale;
bool get showVirtualJoystick => _showVirtualJoystick;
FfiModel ffiModel;
VirtualMouseMode(this.ffiModel);
bool _shouldShow() => !ffiModel.isPeerAndroid;
setShowVirtualMouse(bool b) {
if (b == _showVirtualMouse) return;
if (_shouldShow()) {
_showVirtualMouse = b;
notifyListeners();
}
}
setVirtualMouseScale(double s) {
if (s <= 0) return;
if (s == _virtualMouseScale) return;
_virtualMouseScale = s;
bind.mainSetLocalOption(key: kOptionVirtualMouseScale, value: s.toString());
notifyListeners();
}
setShowVirtualJoystick(bool b) {
if (b == _showVirtualJoystick) return;
if (_shouldShow()) {
_showVirtualJoystick = b;
notifyListeners();
}
}
void loadOptions() {
_showVirtualMouse =
bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y';
_virtualMouseScale = double.tryParse(
bind.mainGetLocalOption(key: kOptionVirtualMouseScale)) ??
1.0;
_showVirtualJoystick =
bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y';
notifyListeners();
}
Future<void> toggleVirtualMouse() async {
await bind.mainSetLocalOption(
key: kOptionShowVirtualMouse, value: showVirtualMouse ? 'N' : 'Y');
setShowVirtualMouse(
bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y');
}
Future<void> toggleVirtualJoystick() async {
await bind.mainSetLocalOption(
key: kOptionShowVirtualJoystick,
value: showVirtualJoystick ? 'N' : 'Y');
setShowVirtualJoystick(
bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y');
}
}
class ImageModel with ChangeNotifier {
ui.Image? _image;
ui.Image? get image => _image;
String id = '';
late final SessionID sessionId;
bool _useTextureRender = false;
WeakReference<FFI> parent;
final List<Function(String)> callbacksOnFirstImage = [];
ImageModel(this.parent) {
sessionId = parent.target!.sessionId;
}
get useTextureRender => _useTextureRender;
addCallbackOnFirstImage(Function(String) cb) => callbacksOnFirstImage.add(cb);
clearImage() => _image = null;
bool _webDecodingRgba = false;
final List<Uint8List> _webRgbaList = List.empty(growable: true);
webOnRgba(int display, Uint8List rgba) async {
// deep copy needed, otherwise "instantiateCodec failed: TypeError: Cannot perform Construct on a detached ArrayBuffer"
_webRgbaList.add(Uint8List.fromList(rgba));
if (_webDecodingRgba) {
return;
}
_webDecodingRgba = true;
try {
while (_webRgbaList.isNotEmpty) {
final rgba2 = _webRgbaList.last;
_webRgbaList.clear();
await decodeAndUpdate(display, rgba2);
}
} catch (e) {
debugPrint('onRgba error: $e');
}
_webDecodingRgba = false;
}
onRgba(int display, Uint8List rgba) async {
try {
await decodeAndUpdate(display, rgba);
} catch (e) {
debugPrint('onRgba error: $e');
}
platformFFI.nextRgba(sessionId, display);
}
// web only: image already created from a decoded WebCodecs frame
Future<void> onImage(
int display, ui.Image image, bool Function() isCurrentSession) async {
await update(image, isCurrentSession: isCurrentSession);
}
decodeAndUpdate(int display, Uint8List rgba) async {
final pid = parent.target?.id;
final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
final image = await img.decodeImageFromPixels(
rgba,
rect?.width.toInt() ?? 0,
rect?.height.toInt() ?? 0,
isWeb | isWindows | isLinux
? ui.PixelFormat.rgba8888
: ui.PixelFormat.bgra8888,
);
if (parent.target?.id != pid) {
image?.dispose();
return;
}
await update(image);
}
Future<void> update(ui.Image? image,
{bool Function()? isCurrentSession}) async {
if (_disposeIfStale(image, isCurrentSession)) return;
if (_image == null && image != null) {
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!);
}
}
if (_disposeIfStale(image, isCurrentSession)) return;
_image?.dispose();
_image = image;
if (image != null) notifyListeners();
}
bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) {
if (image == null || isCurrentSession == null) return false;
if (isCurrentSession()) return false;
image.dispose();
return true;
}
// mobile only
double get maxScale {
if (_image == null) return 1.5;
final size = parent.target!.canvasModel.getSize();
final xscale = size.width / _image!.width;
final yscale = size.height / _image!.height;
return max(1.5, max(xscale, yscale));
}
// mobile only
double get minScale {
if (_image == null) return 1.5;
final size = parent.target!.canvasModel.getSize();
final xscale = size.width / _image!.width;
final yscale = size.height / _image!.height;
return min(xscale, yscale) / 1.5;
}
updateUserTextureRender() {
final preValue = _useTextureRender;
_useTextureRender = isDesktop && bind.mainGetUseTextureRender();
if (preValue != _useTextureRender) {
notifyListeners();
}
}
setUseTextureRender(bool value) {
_useTextureRender = value;
notifyListeners();
}
void disposeImage() {
_image?.dispose();
_image = null;
}
}
enum ScrollStyle {
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 {
final String style;
final double width;
final double height;
final int displayWidth;
final int displayHeight;
ViewStyle({
required this.style,
required this.width,
required this.height,
required this.displayWidth,
required this.displayHeight,
});
static defaultViewStyle() {
final desktop = (isDesktop || isWebDesktop);
final w =
desktop ? kDesktopDefaultDisplayWidth : kMobileDefaultDisplayWidth;
final h =
desktop ? kDesktopDefaultDisplayHeight : kMobileDefaultDisplayHeight;
return ViewStyle(
style: '',
width: w.toDouble(),
height: h.toDouble(),
displayWidth: w,
displayHeight: h,
);
}
static int _double2Int(double v) => (v * 100).round().toInt();
@override
bool operator ==(Object other) =>
other is ViewStyle &&
other.runtimeType == runtimeType &&
_innerEqual(other);
bool _innerEqual(ViewStyle other) {
return style == other.style &&
ViewStyle._double2Int(other.width) == ViewStyle._double2Int(width) &&
ViewStyle._double2Int(other.height) == ViewStyle._double2Int(height) &&
other.displayWidth == displayWidth &&
other.displayHeight == displayHeight;
}
@override
int get hashCode => Object.hash(
style,
ViewStyle._double2Int(width),
ViewStyle._double2Int(height),
displayWidth,
displayHeight,
).hashCode;
double get scale {
double s = 1.0;
if (style == kRemoteViewStyleAdaptive) {
if (width != 0 &&
height != 0 &&
displayWidth != 0 &&
displayHeight != 0) {
final s1 = width / displayWidth;
final s2 = height / displayHeight;
s = s1 < s2 ? s1 : s2;
}
} else if (style == kRemoteViewStyleCustom) {
// Custom scale is session-scoped and applied in CanvasModel.updateViewStyle()
}
return s;
}
}
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;
// image offset of canvas
double _y = 0;
// image scale
double _scale = 1.0;
bool _locked = false;
double _devicePixelRatio = 1.0;
Size _size = Size.zero;
// the tabbar over the image
// double tabBarHeight = 0.0;
// the window border's width
// double windowBorderWidth = 0.0;
// remote id
String id = '';
late final SessionID sessionId;
// scroll offset x percent
double _scrollX = 0.0;
// 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.
bool isMobileCanvasChanged = false;
final ScrollController _horizontal = ScrollController();
final ScrollController _vertical = ScrollController();
final _imageOverflow = false.obs;
WeakReference<FFI> parent;
CanvasModel(this.parent) {
sessionId = parent.target!.sessionId;
}
double get x => _x;
double get y => _y;
double get scale => _scale;
bool get locked => _locked;
double get devicePixelRatio => _devicePixelRatio;
Size get size => _size;
ScrollStyle get scrollStyle => _scrollStyle;
ViewStyle get viewStyle => _lastViewStyle;
RxBool get imageOverflow => _imageOverflow;
void setLocked(bool value) {
if (_locked == value) return;
_locked = value;
notifyListeners();
}
_resetScroll() => setScrollPercent(0.0, 0.0);
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;
ScrollController get scrollVertical => _vertical;
double get scrollX => _scrollX;
double get scrollY => _scrollY;
static double get leftToEdge =>
isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.left : 0;
static double get rightToEdge =>
isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.right : 0;
static double get topToEdge => isDesktop
? tabBarHeight + windowBorderWidth + kDragToResizeAreaPadding.top
: 0;
static double get bottomToEdge =>
isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.bottom : 0;
Size getSize() {
final mediaData = MediaQueryData.fromView(ui.window);
final size = mediaData.size;
// If minimized, w or h may be negative here.
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);
}
// mobile only
double getAdjustY() {
final bottom =
parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ?? 0;
return max(bottom - MediaQueryData.fromView(ui.window).padding.top, 0);
}
updateSize() => _size = getSize();
updateViewStyle({refreshMousePos = true, notify = true}) async {
final style = await bind.sessionGetViewStyle(sessionId: sessionId);
if (style == null) {
return;
}
updateSize();
final displayWidth = getDisplayWidth();
final displayHeight = getDisplayHeight();
final viewStyle = ViewStyle(
style: style,
width: size.width,
height: size.height,
displayWidth: displayWidth,
displayHeight: displayHeight,
);
// If only the Custom scale percent changed, proceed to update even if
// the basic ViewStyle fields are equal.
// In Custom scale mode, the scale percent can change independently of the other
// ViewStyle fields and is not captured by the equality check. Therefore, we must
// allow updates to proceed when style == kRemoteViewStyleCustom, even if the
// rest of the ViewStyle fields are unchanged.
if (_lastViewStyle == viewStyle &&
_devicePixelRatio == ui.window.devicePixelRatio &&
style != kRemoteViewStyleCustom) {
return;
}
if (_lastViewStyle.style != viewStyle.style) {
_resetScroll();
}
_lastViewStyle = viewStyle;
_scale = viewStyle.scale;
// Apply custom scale percent when in Custom mode
if (style == kRemoteViewStyleCustom) {
try {
_scale = await getSessionCustomScale(sessionId);
} catch (e, stack) {
debugPrint('Error in getSessionCustomScale: $e');
debugPrintStack(stackTrace: stack);
_scale = 1.0;
}
}
_devicePixelRatio = ui.window.devicePixelRatio;
if (kIgnoreDpi) {
if (style == kRemoteViewStyleOriginal) {
_scale = 1.0 / _devicePixelRatio;
} else if (_scale != 0 && style == kRemoteViewStyleCustom) {
_scale /= _devicePixelRatio;
}
}
_resetCanvasOffset(displayWidth, displayHeight);
final overflow = _x < 0 || y < 0;
if (_imageOverflow.value != overflow) {
_imageOverflow.value = overflow;
}
if (notify) {
notifyListeners();
}
if (!isMobile && refreshMousePos) {
parent.target?.inputModel.refreshMousePos();
}
tryUpdateScrollStyle(Duration.zero, style);
}
_resetCanvasOffset(int displayWidth, int displayHeight) {
_x = (size.width - displayWidth * _scale) / 2;
_y = (size.height - displayHeight * _scale) / 2;
if (isMobile) {
_moveToCenterCursor();
}
}
tryUpdateScrollStyle(Duration duration, String? style) async {
if (_scrollStyle == ScrollStyle.scrollauto) return;
style ??= await bind.sessionGetViewStyle(sessionId: sessionId);
if (style != kRemoteViewStyleOriginal && style != kRemoteViewStyleCustom) {
return;
}
_resetScroll();
Future.delayed(duration, () async {
// Layout updates scroll extents and detaches scrollbars no longer needed.
await SchedulerBinding.instance.endOfFrame;
updateScrollPercent();
});
}
Future<void> updateScrollStyle() async {
final style = await bind.sessionGetScrollStyle(sessionId: sessionId);
_scrollStyle =
style != null ? ScrollStyle.fromString(style) : ScrollStyle.scrollauto;
if (_scrollStyle != ScrollStyle.scrollauto) {
// Scrollbar and Scroll Edge share controllers and retain their positions.
updateScrollPercent();
}
notifyListeners();
}
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;
notifyListeners();
}
bool get cursorEmbedded =>
parent.target?.ffiModel._pi.cursorEmbedded ?? false;
int getDisplayWidth() {
final defaultWidth = (isDesktop || isWebDesktop)
? kDesktopDefaultDisplayWidth
: kMobileDefaultDisplayWidth;
return parent.target?.ffiModel.rect?.width.toInt() ?? defaultWidth;
}
int getDisplayHeight() {
final defaultHeight = (isDesktop || isWebDesktop)
? kDesktopDefaultDisplayHeight
: kMobileDefaultDisplayHeight;
return parent.target?.ffiModel.rect?.height.toInt() ?? defaultHeight;
}
static double get windowBorderWidth => stateGlobal.windowBorderWidth.value;
static double get tabBarHeight => stateGlobal.tabBarHeight;
void activateLocalCursor() {
if (isDesktop || isWebDesktop) {
try {
RemoteCursorMovedState.find(id).value = false;
} catch (e) {
//
}
}
}
void updateLocalCursor(double x, double y) {
if (parent.target?.ffiModel.viewOnly == true) return;
// 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;
}
// On mobile platforms, move the canvas with the cursor.
final dw = getDisplayWidth() * _scale;
final dh = getDisplayHeight() * _scale;
var dxOffset = 0;
var dyOffset = 0;
try {
if (dw > size.width) {
dxOffset = (x - dw * (x / size.width) - _x).toInt();
}
if (dh > size.height) {
dyOffset = (y - dh * (y / size.height) - _y).toInt();
}
} catch (e) {
debugPrintStack(
label:
'(x,y) ($x,$y), (_x,_y) ($_x,$_y), _scale $_scale, display size (${getDisplayWidth()},${getDisplayHeight()}), size $size, , $e');
return;
}
_x += dxOffset;
_y += dyOffset;
if (dxOffset != 0 || dyOffset != 0) {
notifyListeners();
}
}
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 {
// 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();
}
}
}
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();
}
panX(double dx) {
_x += dx;
if (isMobile) {
isMobileCanvasChanged = true;
}
notifyListeners();
}
resetOffset() {
if (isWebDesktop) {
updateViewStyle();
} else {
_resetCanvasOffset(getDisplayWidth(), getDisplayHeight());
}
notifyListeners();
}
panY(double dy) {
_y += dy;
if (isMobile) {
isMobileCanvasChanged = true;
}
notifyListeners();
}
// mobile only
updateScale(double v, Offset focalPoint) {
if (parent.target?.imageModel.image == null) return;
final s = _scale;
_scale *= v;
final maxs = parent.target?.imageModel.maxScale ?? 1;
final mins = parent.target?.imageModel.minScale ?? 1;
if (_scale > maxs) _scale = maxs;
if (_scale < mins) _scale = mins;
// (focalPoint.dx - _x_1) / s1 + displayOriginX = (focalPoint.dx - _x_2) / s2 + displayOriginX
// _x_2 = focalPoint.dx - (focalPoint.dx - _x_1) / s1 * s2
_x = focalPoint.dx - (focalPoint.dx - _x) / s * _scale;
final adjust = getAdjustY();
// (focalPoint.dy - _y_1 - adjust) / s1 + displayOriginY = (focalPoint.dy - _y_2 - adjust) / s2 + displayOriginY
// _y_2 = focalPoint.dy - adjust - (focalPoint.dy - _y_1 - adjust) / s1 * s2
_y = focalPoint.dy - adjust - (focalPoint.dy - _y - adjust) / s * _scale;
if (isMobile) {
isMobileCanvasChanged = true;
}
notifyListeners();
}
// For reset canvas to the last view style
reset() {
_scale = _lastViewStyle.scale;
_devicePixelRatio = ui.window.devicePixelRatio;
if (kIgnoreDpi && _lastViewStyle.style == kRemoteViewStyleOriginal) {
_scale = 1.0 / _devicePixelRatio;
}
_resetCanvasOffset(getDisplayWidth(), getDisplayHeight());
bind.sessionSetViewStyle(sessionId: sessionId, value: _lastViewStyle.style);
notifyListeners();
}
clear() {
_x = 0;
_y = 0;
_scale = 1.0;
_locked = false;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel();
_offsetBeforeMobileSoftKeyboard = null;
_scaleBeforeMobileSoftKeyboard = null;
}
updateScrollPercent() {
final percentX = _horizontal.hasClients
? _horizontal.position.extentBefore /
(_horizontal.position.extentBefore +
_horizontal.position.extentInside +
_horizontal.position.extentAfter)
: 0.0;
final percentY = _vertical.hasClients
? _vertical.position.extentBefore /
(_vertical.position.extentBefore +
_vertical.position.extentInside +
_vertical.position.extentAfter)
: 0.0;
setScrollPercent(percentX, percentY);
}
void mobileFocusCanvasCursor() {
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileFocusCanvasCursor =
Timer(Duration(milliseconds: 100), () async {
updateSize();
_resetCanvasOffset(getDisplayWidth(), getDisplayHeight());
notifyListeners();
});
}
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() {
Rect? imageRect = parent.target?.ffiModel.rect;
if (imageRect == null) {
// unreachable
return;
}
final maxX = 0.0;
final minX = _size.width + (imageRect.left - imageRect.right) * _scale;
final maxY = 0.0;
final minY = _size.height + (imageRect.top - imageRect.bottom) * _scale;
Offset offsetToCenter =
parent.target?.cursorModel.getCanvasOffsetToCenterCursor() ??
Offset.zero;
if (minX < 0) {
_x = min(max(offsetToCenter.dx, minX), maxX);
} else {
// _size.width > (imageRect.right, imageRect.left) * _scale, we should not change _x
}
if (minY < 0) {
_y = min(max(offsetToCenter.dy, minY), maxY);
} else {
// _size.height > (imageRect.bottom - imageRect.top) * _scale, , we should not change _y
}
}
}
// data for cursor
class CursorData {
// At most 1 MiB of RGBA, including Linux's square cursor padding: twice the largest
// accessibility cursor, beyond which a cursor is shown smaller rather than larger.
static const _maxRasterSize = 512;
final String peerId;
final String id;
img2.Image? _image;
double scale;
Uint8List? data;
final double hotxOrigin;
final double hotyOrigin;
double hotx;
double hoty;
final int width;
final int height;
int _rasterWidth;
int _rasterHeight;
bool _scaleLimitReported = false;
int get rasterWidth => _rasterWidth;
int get rasterHeight => _rasterHeight;
img2.Image get image => _image!;
/// False for a shape not in use; only the shape in use keeps pixels, for a new raster.
bool get hasPixels => _image != null;
void releasePixels() {
_image = null;
data = null;
}
CursorData({
required this.peerId,
required this.id,
required img2.Image image,
required this.scale,
required this.data,
required this.hotxOrigin,
required this.hotyOrigin,
required this.width,
required this.height,
}) : _image = image,
_rasterWidth = (width * scale).ceil(),
_rasterHeight = (height * scale).ceil(),
hotx = hotxOrigin * scale,
hoty = hotyOrigin * scale;
int _doubleToInt(double v) => (v * 10e6).round().toInt();
double _limitScale(double requestedScale) {
final valid = requestedScale.isFinite && requestedScale > 0;
// Invalid requests retain the last valid raster and hotspot.
final limitedScale = valid
? min(requestedScale, _maxRasterSize / max(width, height))
: scale;
final limited = !valid || limitedScale != requestedScale;
if (limited && !_scaleLimitReported) {
debugPrint(
'Cursor $id: rejected scale $requestedScale for ${width}x$height; '
'using $limitedScale (maximum raster side $_maxRasterSize).');
}
_scaleLimitReported = limited;
return limitedScale;
}
double _checkUpdateScale(double scale) {
scale = _limitScale(scale);
if (scale != 1.0) {
// A thin cursor must not grow just to make its short edge reach the minimum.
scale = max(scale, kMinCursorSize / max(width, height));
}
final targetWidth = (width * scale).ceil();
final targetHeight = (height * scale).ceil();
if ((_rasterWidth != targetWidth || _rasterHeight != targetHeight) &&
!hasPixels) {
// Nothing to make this raster from; buildCursorOfCache asks for the pixels unless its
// native cursor was made before.
data = null;
return scale;
}
if (_rasterWidth != targetWidth || _rasterHeight != targetHeight) {
if (isWindows) {
data = img2
.copyResize(
image,
width: targetWidth,
height: targetHeight,
interpolation: img2.Interpolation.average,
)
.getBytes(order: img2.ChannelOrder.bgra);
} else if (isDesktop && scale < 1.0 && !image.hasPalette) {
data = Uint8List.fromList(
img2.encodePng(_resizeWithAlpha(targetWidth, targetHeight)));
} else {
data = Uint8List.fromList(
img2.encodePng(
img2.copyResize(
image,
width: targetWidth,
height: targetHeight,
interpolation: img2.Interpolation.average,
),
),
);
}
_rasterWidth = targetWidth;
_rasterHeight = targetHeight;
}
this.scale = scale;
hotx = hotxOrigin * _rasterWidth / width;
hoty = hotyOrigin * _rasterHeight / height;
return scale;
}
img2.Image _resizeWithAlpha(int targetWidth, int targetHeight) {
final resized =
img2.Image.fromResized(image, width: targetWidth, height: targetHeight);
final dx = image.width / targetWidth;
final dy = image.height / targetHeight;
// Use the average filter's sample area, but weight RGB by alpha so
// transparent pixels do not darken visible edges in the straight-alpha PNG.
for (final pixel in resized) {
final x = (pixel.x * dx).toInt();
final y = (pixel.y * dy).toInt();
final sampleWidth = ((pixel.x + 1) * dx).toInt() - x;
final sampleHeight = ((pixel.y + 1) * dy).toInt() - y;
final samples = image.getRange(x, y, sampleWidth, sampleHeight);
num r = 0;
num g = 0;
num b = 0;
num a = 0;
while (samples.moveNext()) {
final sample = samples.current;
r += sample.r * sample.a;
g += sample.g * sample.a;
b += sample.b * sample.a;
a += sample.a;
}
if (a == 0) {
pixel.setRgba(0, 0, 0, 0);
continue;
}
pixel.setRgba(r / a, g / a, b / a, a / (sampleWidth * sampleHeight));
}
return resized;
}
String updateGetKey(double scale) {
scale = _checkUpdateScale(scale);
// The raster asked for, not the one made last: a shape without pixels keeps the native
// cursors of every raster it was shown at.
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}_${(width * scale).ceil()}_${(height * scale).ceil()}';
}
}
const _forbiddenCursorPng =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAkZQTFRFAAAA2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4G2B4GWAwCAAAAAAAA2B4GAAAAMTExAAAAAAAA2B4G2B4G2B4GAAAAmZmZkZGRAQEBAAAA2B4G2B4G2B4G////oKCgAwMDag8D2B4G2B4G2B4Gra2tBgYGbg8D2B4G2B4Gubm5CQkJTwsCVgwC2B4GxcXFDg4OAAAAAAAA2B4G2B4Gz8/PFBQUAAAAAAAA2B4G2B4G2B4G2B4G2B4G2B4G2B4GDgIA2NjYGxsbAAAAAAAA2B4GFwMB4eHhIyMjAAAAAAAA2B4G6OjoLCwsAAAAAAAA2B4G2B4G2B4G2B4G2B4GCQEA4ODgv7+/iYmJY2NjAgICAAAA9PT0Ojo6AAAAAAAAAAAA+/v7SkpKhYWFr6+vAAAAAAAA8/PzOTk5ERER9fX1KCgoAAAAgYGBKioqAAAAAAAApqamlpaWAAAAAAAAAAAAAAAAAAAAAAAALi4u/v7+GRkZAAAAAAAAAAAAAAAAAAAAfn5+AAAAAAAAV1dXkJCQAAAAAAAAAQEBAAAAAAAAAAAA7Hz6BAAAAMJ0Uk5TAAIWEwEynNz6//fVkCAatP2fDUHs6cDD8d0mPfT5fiEskiIR584A0gejr3AZ+P4plfALf5ZiTL85a4ziD6697fzN3UYE4v/4TwrNHuT///tdRKZh///+1U/ZBv///yjb///eAVL//50Cocv//6oFBbPvpGZCbfT//7cIhv///8INM///zBEcWYSZmO7//////1P////ts/////8vBv//////gv//R/z///QQz9sevP///2waXhNO/+fc//8mev/5gAe2r90MAAAByUlEQVR4nGNggANGJmYWBpyAlY2dg5OTi5uHF6s0H78AJxRwCAphyguLgKRExcQlQLSkFLq8tAwnp6ycPNABjAqKQKNElVDllVU4OVVhVquJA81Q10BRoAkUUYbJa4Edoo0sr6PLqaePLG/AyWlohKTAmJPTBFnelAFoixmSAnNOTgsUeQZLTk4rJAXWnJw2EHlbiDyDPCenHZICe04HFrh+RydnBgYWPU5uJAWinJwucPNd3dw9GDw5Ob2QFHBzcnrD7ffx9fMPCOTkDEINhmC4+3x8Q0LDwlEDIoKTMzIKKg9SEBIdE8sZh6SAJZ6Tkx0qD1YQkpCYlIwclCng0AXLQxSEpKalZyCryATKZwkhKQjJzsnNQ1KQXwBUUVhUXBJYWgZREFJeUVmFpMKlWg+anmqgCkJq6+obkG1pLEBTENLU3NKKrIKhrb2js8u4G6Kgpze0r3/CRAZMAHbkpJDJU6ZMmTqtFbuC6TNmhsyaMnsOFlmwgrnzpsxfELJwEXZ5Bp/FS3yWLlsesmLlKuwKVk9Ys5Zh3foN0zduwq5g85atDAzbpqSGbN9RhV0FGOzctWH3lD14FOzdt3H/gQw8Cg4u2gQPAwBYDXXdIH+wqAAAAABJRU5ErkJggg==';
const _defaultCursorPng =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAAAFmSURBVFiF7dWxSlxREMbx34QFDRowYBchZSxSCWlMCOwD5FGEFHap06UI7KPsAyyEEIQFqxRaCqYTsqCJFsKkuAeRXb17wrqV918dztw55zszc2fo6Oh47MR/e3zO1/iAHWmznHKGQwx9ip/LEbCfazbsoY8j/JLOhcC6sCW9wsjEwJf483AC9nPNc1+lFRwI13d+l3rYFS799rFGxJMqARv2pBXh+72XQ7gWvklPS7TmMl9Ak/M+DqrENvxAv/guKKApuKPWl0/TROK4+LbSqzhuB+OZ3fRSeFPWY+Fkyn56Y29hfgTSpnQ+s98cvorVey66uPlNFxKwZOYLCGfCs5n9NMYVrsp6mvXSoFqpqYFDvMBkStgJJe93dZOwVXxbqUnBENulydSReqUrDhcX0PT2EXarBYS3GNXMhboinBgIl9K71kg0L3+PvyYGdVpruT2MwrF0iotiXfIwus0Dj+OOjo6Of+e7ab74RkpgAAAAAElFTkSuQmCC';
const kPreForbiddenCursorId = "-2";
final preForbiddenCursor = PredefinedCursor(
png: _forbiddenCursorPng,
id: kPreForbiddenCursorId,
);
const kPreDefaultCursorId = "-1";
final preDefaultCursor = PredefinedCursor(
png: _defaultCursorPng,
id: kPreDefaultCursorId,
hotxGetter: (double w) => w / 2,
hotyGetter: (double h) => h / 2,
);
class PredefinedCursor {
ui.Image? _image;
img2.Image? _image2;
CursorData? _cache;
String png;
String id;
double Function(double)? hotxGetter;
double Function(double)? hotyGetter;
PredefinedCursor(
{required this.png, required this.id, this.hotxGetter, this.hotyGetter}) {
init();
}
ui.Image? get image => _image;
CursorData? get cache => _cache;
init() {
_image2 = img2.decodePng(base64Decode(png));
if (_image2 != null) {
// The png type of forbidden cursor image is `PngColorType.indexed`.
if (id == kPreForbiddenCursorId) {
_image2 = _image2!.convert(format: img2.Format.uint8, numChannels: 4);
}
() async {
final defaultImg = _image2!;
// This function is called only one time, no need to care about the performance.
Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba);
_image?.dispose();
_image = await img.decodeImageFromPixels(
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
if (_image == null) {
print("decodeImageFromPixels failed, pre-defined cursor $id");
return;
}
double scale = 1.0;
if (isWindows) {
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
} else {
data = Uint8List.fromList(img2.encodePng(_image2!));
}
_cache = CursorData(
peerId: '',
id: id,
image: _image2!.clone(),
scale: scale,
data: data,
hotxOrigin:
hotxGetter != null ? hotxGetter!(_image2!.width.toDouble()) : 0,
hotyOrigin:
hotyGetter != null ? hotyGetter!(_image2!.height.toDouble()) : 0,
width: _image2!.width,
height: _image2!.height,
);
}();
}
}
}
class CursorModel with ChangeNotifier {
ui.Image? _image;
final _images = <String, Tuple3<ui.Image, double, double>>{};
CursorData? _cache;
final _cacheMap = <String, CursorData>{};
final _cacheKeys = <String>{};
double _x = -10000;
double _y = -10000;
// int.parse(evt['id']) may cause FormatException
// So we use String here.
String _id = "-1";
double _hotx = 0;
double _hoty = 0;
double _displayOriginX = 0;
double _displayOriginY = 0;
DateTime? _firstUpdateMouseTime;
Rect? _windowRect;
List<RemoteWindowCoords> _remoteWindowCoords = [];
bool gotMouseControl = true;
DateTime _lastPeerMouse = DateTime.now()
.subtract(Duration(milliseconds: 3000 * kMouseControlTimeoutMSec));
String peerId = '';
WeakReference<FFI> parent;
// Only for mobile, touch mode
// To block touch event above the KeyHelpTools
//
// A better way is to not listen events from the KeyHelpTools.
// But we're now using a Container(child: Stack(...)) to wrap the KeyHelpTools,
// and the listener is on the Container.
Rect? _keyHelpToolsRect;
// `lastIsBlocked` is only used in common/widgets/remote_input.dart -> _RawTouchGestureDetectorRegionState -> onDoubleTap()
// Because onDoubleTap() doesn't have the `event` parameter, we can't get the touch event's position.
bool _lastIsBlocked = false;
bool _lastKeyboardIsVisible = false;
bool get lastKeyboardIsVisible => _lastKeyboardIsVisible;
Rect? get keyHelpToolsRectToAdjustCanvas =>
_lastKeyboardIsVisible ? _keyHelpToolsRect : null;
// The blocked rect is used to block the pointer/touch events in the remote page.
final List<Rect> _blockedRects = [];
// Used in shouldBlock().
// _blockEvents is a flag to block pointer/touch events on the remote image.
// It is set to true to prevent accidental touch events in the following scenarios:
// 1. In floating mouse mode, when the scroll circle is shown.
// 2. In floating mouse widgets mode, when the left/right buttons are moving.
// 3. In floating mouse widgets mode, when using the virtual joystick.
// When _blockEvents is true, all pointer/touch events are blocked regardless of the contents of _blockedRects.
// _blockedRects contains specific rectangular regions where events are blocked; these are checked when _blockEvents is false.
// In summary: _blockEvents acts as a global block, while _blockedRects provides fine-grained blocking.
bool _blockEvents = false;
List<Rect> get blockedRects => List.unmodifiable(_blockedRects);
set blockEvents(bool v) => _blockEvents = v;
keyHelpToolsVisibilityChanged(Rect? rect, bool keyboardIsVisible) {
_keyHelpToolsRect = rect;
if (rect == null) {
_lastIsBlocked = false;
} else {
// Block the touch event is safe here.
// `lastIsBlocked` is only used in onDoubleTap() to block the touch event from the KeyHelpTools.
// `lastIsBlocked` will be set when the cursor is moving or touch somewhere else.
_lastIsBlocked = true;
}
if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) {
if (keyboardIsVisible) {
parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard();
parent.target?.canvasModel.mobileFocusCanvasCursor();
parent.target?.canvasModel.isMobileCanvasChanged = false;
} else {
parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard();
}
}
_lastKeyboardIsVisible = keyboardIsVisible;
}
addBlockedRect(Rect rect) {
_blockedRects.add(rect);
}
removeBlockedRect(Rect rect) {
_blockedRects.remove(rect);
}
get lastIsBlocked => _lastIsBlocked;
/// The image of the shape in use, or the one shown before until it is back: a switch falls
/// back to the default cursor only for a shape the core cannot give. Asks the core for the
/// shape in use when it was let go.
ui.Image? get image {
if (_images[_id] == null && _cacheMap.containsKey(_id)) {
restorePixels(_id);
}
return _image;
}
CursorData? get cache => _cache;
double get x => _x - _displayOriginX;
double get y => _y - _displayOriginY;
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
Offset get offset => Offset(_x, _y);
double get hotx => _hotx;
double get hoty => _hoty;
set id(String id) {
if (_id != id) _unavailable = null;
_id = id;
}
bool get isPeerControlProtected =>
DateTime.now().difference(_lastPeerMouse).inMilliseconds <
kMouseControlTimeoutMSec;
bool isConnIn2Secs() {
if (_firstUpdateMouseTime == null) {
_firstUpdateMouseTime = DateTime.now();
return true;
} else {
return DateTime.now().difference(_firstUpdateMouseTime!).inSeconds < 2;
}
}
CursorModel(this.parent) {
// Made now, not when first drawn: nothing draws again when their decode lands, so the first
// build that needs one would show the shape before in its place.
preDefaultCursor.cache;
preForbiddenCursor.cache;
}
Set<String> get cachedKeys => _cacheKeys;
addKey(String key) => _cacheKeys.add(key);
// remote physical display coordinate
// For update pan (mobile), onOneFingerPanStart, onOneFingerPanUpdate, onHoldDragUpdate
Rect getVisibleRect() {
final size = parent.target?.canvasModel.getSize() ??
MediaQueryData.fromView(ui.window).size;
final xoffset = parent.target?.canvasModel.x ?? 0;
final yoffset = parent.target?.canvasModel.y ?? 0;
final scale = parent.target?.canvasModel.scale ?? 1;
final x0 = _displayOriginX - xoffset / scale;
final y0 = _displayOriginY - yoffset / scale;
return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale);
}
Offset getCanvasOffsetToCenterCursor() {
// Cursor should be at the center of the visible rect.
// _x = rect.left + rect.width / 2
// _y = rect.right + rect.height / 2
// See `getVisibleRect()`
// _x = _displayOriginX - xoffset / scale + size.width / scale * 0.5;
// _y = _displayOriginY - yoffset / scale + size.height / scale * 0.5;
final size = parent.target?.canvasModel.getSize() ??
MediaQueryData.fromView(ui.window).size;
final xoffset = (_displayOriginX - _x) * scale + size.width * 0.5;
final yoffset = (_displayOriginY - _y) * scale + size.height * 0.5;
return Offset(xoffset, yoffset);
}
get scale => parent.target?.canvasModel.scale ?? 1.0;
// mobile Soft keyboard, block touch event from the KeyHelpTools
shouldBlock(double x, double y) {
if (_blockEvents) {
return true;
}
final offset = Offset(x, y);
for (final rect in _blockedRects) {
if (isPointInRect(offset, rect)) {
return true;
}
}
// For help tools rectangle, only block touch event when in touch mode.
if (!(parent.target?.ffiModel.touchMode ?? false)) {
return false;
}
if (_keyHelpToolsRect != null &&
isPointInRect(offset, _keyHelpToolsRect!)) {
return true;
}
return false;
}
// For touch mode
Future<bool> move(double x, double y) async {
if (shouldBlock(x, y)) {
_lastIsBlocked = true;
return false;
}
_lastIsBlocked = false;
if (!_moveLocalIfInRemoteRect(x, y)) {
return false;
}
await parent.target?.inputModel.moveMouse(_x, _y);
return true;
}
Future<void> syncCursorPosition() async {
await parent.target?.inputModel.moveMouse(_x, _y);
}
bool isInRemoteRect(Offset offset) {
return getRemotePosInRect(offset) != null;
}
Offset? getRemotePosInRect(Offset offset) {
final adjust = parent.target?.canvasModel.getAdjustY() ?? 0;
final newPos = _getNewPos(offset.dx, offset.dy, adjust);
final visibleRect = getVisibleRect();
if (!isPointInRect(newPos, visibleRect)) {
return null;
}
final rect = parent.target?.ffiModel.rect;
if (rect != null) {
if (!isPointInRect(newPos, rect)) {
return null;
}
}
return newPos;
}
Offset _getNewPos(double x, double y, double adjust) {
final xoffset = parent.target?.canvasModel.x ?? 0;
final yoffset = parent.target?.canvasModel.y ?? 0;
final newX = (x - xoffset) / scale + _displayOriginX;
final newY = (y - yoffset - adjust) / scale + _displayOriginY;
return Offset(newX, newY);
}
bool _moveLocalIfInRemoteRect(double x, double y) {
final newPos = getRemotePosInRect(Offset(x, y));
if (newPos == null) {
return false;
}
_x = newPos.dx;
_y = newPos.dy;
notifyListeners();
return true;
}
moveLocal(double x, double y, {double adjust = 0}) {
final newPos = _getNewPos(x, y, adjust);
_x = newPos.dx;
_y = newPos.dy;
notifyListeners();
}
reset() {
_x = _displayOriginX;
_y = _displayOriginY;
parent.target?.inputModel.moveMouse(_x, _y);
parent.target?.canvasModel.reset();
notifyListeners();
}
updatePan(Offset delta, Offset localPosition, bool touchMode) async {
if (touchMode) {
await _handleTouchMode(delta, localPosition);
return;
}
double dx = delta.dx;
double dy = delta.dy;
if (parent.target?.imageModel.image == null) return;
final scale = parent.target?.canvasModel.scale ?? 1.0;
dx /= scale;
dy /= scale;
final r = getVisibleRect();
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 +
(displayRect?.width ?? 1280) -
r.right.roundToDouble();
tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0;
if (tryMoveCanvasX) {
dx = min(dx, maxCanvasCanMove);
} else {
final maxCursorCanMove = r.right - _x;
dx = min(dx, maxCursorCanMove);
}
} else if (dx < 0) {
final maxCanvasCanMove = _displayOriginX - r.left.roundToDouble();
tryMoveCanvasX = _x + dx < cx && maxCanvasCanMove < 0;
if (tryMoveCanvasX) {
dx = max(dx, maxCanvasCanMove);
} else {
final maxCursorCanMove = r.left - _x;
dx = max(dx, maxCursorCanMove);
}
}
var tryMoveCanvasY = false;
if (dy > 0) {
final mayCanvasCanMove = _displayOriginY +
(displayRect?.height ?? 720) -
r.bottom.roundToDouble();
tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0;
if (tryMoveCanvasY) {
dy = min(dy, mayCanvasCanMove);
} else {
final mayCursorCanMove = r.bottom - _y;
dy = min(dy, mayCursorCanMove);
}
} else if (dy < 0) {
final mayCanvasCanMove = _displayOriginY - r.top.roundToDouble();
tryMoveCanvasY = _y + dy < cy && mayCanvasCanMove < 0;
if (tryMoveCanvasY) {
dy = max(dy, mayCanvasCanMove);
} else {
final mayCursorCanMove = r.top - _y;
dy = max(dy, mayCursorCanMove);
}
}
if (dx == 0 && dy == 0) return;
Point<double>? newPos;
final rect = parent.target?.ffiModel.rect;
if (rect == null) {
// unreachable
return;
}
newPos = InputModel.getPointInRemoteRect(
false,
parent.target?.ffiModel.pi.platform,
kPointerEventKindMouse,
kMouseEventTypeDefault,
_x + dx,
_y + dy,
rect,
buttons: kPrimaryButton);
if (newPos == null) {
return;
}
dx = newPos.x - _x;
dy = newPos.y - _y;
_x = newPos.x;
_y = newPos.y;
if (tryMoveCanvasX && dx != 0) {
parent.target?.canvasModel.panX(-dx * scale);
}
if (tryMoveCanvasY && dy != 0) {
parent.target?.canvasModel.panY(-dy * scale);
}
parent.target?.inputModel.moveMouse(_x, _y);
notifyListeners();
}
bool _isInCurrentWindow(double x, double y) {
final w = _windowRect!.width / devicePixelRatio;
final h = _windowRect!.width / devicePixelRatio;
return x >= 0 && y >= 0 && x <= w && y <= h;
}
_handleTouchMode(Offset delta, Offset localPosition) async {
bool isMoved = false;
if (_remoteWindowCoords.isNotEmpty &&
_windowRect != null &&
!_isInCurrentWindow(localPosition.dx, localPosition.dy)) {
final coords = InputModel.findRemoteCoords(localPosition.dx,
localPosition.dy, _remoteWindowCoords, devicePixelRatio);
if (coords != null) {
double x2 =
(localPosition.dx - coords.relativeOffset.dx / devicePixelRatio) /
coords.canvas.scale;
double y2 =
(localPosition.dy - coords.relativeOffset.dy / devicePixelRatio) /
coords.canvas.scale;
x2 += coords.cursor.offset.dx;
y2 += coords.cursor.offset.dy;
await parent.target?.inputModel.moveMouse(x2, y2);
isMoved = true;
}
}
if (!isMoved) {
final rect = parent.target?.ffiModel.rect;
if (rect == null) {
// unreachable
return;
}
Offset? movementInRect(double x, double y, Rect r) {
final isXInRect = x >= r.left && x <= r.right;
final isYInRect = y >= r.top && y <= r.bottom;
if (!(isXInRect || isYInRect)) {
return null;
}
if (x < r.left) {
x = r.left;
} else if (x > r.right) {
x = r.right;
}
if (y < r.top) {
y = r.top;
} else if (y > r.bottom) {
y = r.bottom;
}
return Offset(x, y);
}
final scale = parent.target?.canvasModel.scale ?? 1.0;
var movement =
movementInRect(_x + delta.dx / scale, _y + delta.dy / scale, rect);
if (movement == null) {
return;
}
movement = movementInRect(movement.dx, movement.dy, getVisibleRect());
if (movement == null) {
return;
}
_x = movement.dx;
_y = movement.dy;
await parent.target?.inputModel.moveMouse(_x, _y);
}
notifyListeners();
}
disposeImages() {
_images.forEach((_, v) => v.item1.dispose());
_images.clear();
}
/// Its image is kept with the ones used last, see [_imageLimit]; a shape that does not decode
/// is marked as one the core cannot give.
Future<void> updateCursorData(String id, int hotxInt, int hotyInt, int width,
int height, Uint8List rgba) async {
final generation = _generation;
if (_unavailable == id) _unavailable = null;
final hotx = hotxInt.toDouble();
final hoty = hotyInt.toDouble();
final image = await img.decodeImageFromPixels(
rgba, width, height, ui.PixelFormat.rgba8888);
if (image == null) {
// It did not decode; painting must not ask for it on every frame.
_markUnavailable(generation, id);
return;
}
if (!await _updateCache(
generation, rgba, image, id, hotx, hoty, width, height)) {
// Not kept, or the session was cleared while it decoded.
image.dispose();
_markUnavailable(generation, id);
return;
}
final old = _images.remove(id);
_images[id] = Tuple3(image, hotx, hoty);
if (old != null) {
if (identical(old.item1, _image)) _image = image;
old.item1.dispose();
}
// Update last cursor data.
// Do not use the previous `image` and `id`, because `_id` may be changed.
_updateCurData();
final cache = _cacheMap[id];
if (id != _id && cache != null) _switchedAway(cache);
}
Future<bool> _updateCache(
int generation,
Uint8List rgba,
ui.Image image,
String id,
double hotx,
double hoty,
int w,
int h,
) async {
Uint8List? data;
img2.Image imgOrigin = img2.Image.fromBytes(
width: w,
height: h,
bytes: rgba.buffer,
bytesOffset: rgba.offsetInBytes,
order: img2.ChannelOrder.rgba);
if (isWindows) {
data = imgOrigin.getBytes(order: img2.ChannelOrder.bgra);
} else {
ByteData? imgBytes =
await image.toByteData(format: ui.ImageByteFormat.png);
if (imgBytes == null) {
return false;
}
data = imgBytes.buffer.asUint8List();
if (isDesktop &&
(parent.target?.ffiModel.isPeerLinux == true ||
parent.target?.ffiModel.isPeerWindows == true)) {
// PNG decoding supplies straight alpha for Linux/Windows cursor resizing.
final decoded = img2.decodePng(data);
if (decoded == null) {
debugPrint('Unable to decode cursor $id PNG for resizing');
return false;
}
imgOrigin = decoded;
}
}
final cache = CursorData(
peerId: peerId,
id: id,
image: imgOrigin,
scale: 1.0,
data: data,
hotxOrigin: hotx,
hotyOrigin: hoty,
width: w,
height: h,
);
if (generation != _generation) {
return false;
}
_cacheMap[id] = cache;
return true;
}
@visibleForTesting
Iterable<String> get shapeIds => _images.keys;
@visibleForTesting
CursorData? cachedShape(String id) => _cacheMap[id];
final _restoring = <String>{};
// Tabs in a window share its engine's native cursors, predefined ones included.
static int _nextKeyScope = 0;
final int _keyScope = _nextKeyScope++;
String nativeKey(CursorData cache, double scale) {
final key = '${_keyScope}_${cache.updateGetKey(scale)}';
// A native cursor at another raster does not hold the pixels this one is made from.
if (!_cacheKeys.contains(key)) _nativeIds.remove(cache.id);
return key;
}
// Native cursors stay for the session, one per raster of each shape, and only [clear]
// deletes them, as before; unlike the painted images they have no LRU, for now. A delete
// frees nothing on Windows: the engine's `deleteCustomCursor/windows` releases the HCURSOR
// with `DeleteObject`, which does not take a cursor. Measured on Windows with 500 cursors
// made by `CreateIconIndirect`, as the engine makes them: `DeleteObject` failed all 500 and
// left 500 USER objects alive, while `DestroyCursor` or `DestroyIcon` freed all 500. Each
// delete leaks its handle until the process exits, and making the cursor again for a raster
// or a shape shown once more leaks another, so an LRU would leak more than keeping them.
//
// Fixing the engine is not cheap. x64 runs our fork, rustdesk/engine, on Flutter 3.24; the
// fix is one more patch to rebuild, publish, and carry across every Flutter upgrade. arm64
// runs the stock engine of a newer Flutter, which has the same code: fixing it means
// porting the fork to that version and building and publishing an arm64 engine as well, or
// an upstream fix and waiting for it to reach stable. Once both engines destroy cursors, an
// LRU like the painted images' can come back. Content ids keep the count to the shapes the
// peer really shows, a few dozen to a couple of hundred.
/// The shape in use keeps its pixels, so a new raster is made from them at once; the others
/// keep none once their native cursor holds them.
void registered(CursorData cache, String key) {
_nativeIds.add(cache.id);
_awaitingNative.remove(cache.id);
if (cache.id != _id && identical(_cacheMap[cache.id], cache)) {
cache.releasePixels();
}
}
// The shapes a native cursor holds, and those keeping their pixels until one does, oldest
// first: one decoded after the peer moved on must still get a native cursor when shown
// again, rather than be decoded again every time it comes back.
final _nativeIds = <String>{};
final _awaitingNative = <String>{};
void _switchedAway(CursorData cache) {
_awaitingNative.remove(cache.id);
// Mobile only paints the cursor, from its image.
if (isMobile || _nativeIds.contains(cache.id)) {
cache.releasePixels();
return;
}
_awaitingNative.add(cache.id);
while (_awaitingNative.length > kRecentShapes) {
final oldest = _awaitingNative.first;
_awaitingNative.remove(oldest);
if (oldest != _id) _cacheMap[oldest]?.releasePixels();
}
}
/// The peer's shapes used last that keep a painted image, and the most that keep their pixels
/// waiting for a native cursor; the core rebuilds the others. An animated cursor is a shape per
/// frame, 18 for the Windows busy cursor and 23 for KDE's, and a cycle longer than this limit
/// would rebuild every frame.
static const kRecentShapes = 64;
/// The native cursor of the shape shown last, shown on while the shape in use is made.
String? shownKey;
void shown(CursorData cache, String key) {
// The forbidden cursor shows only while input is off; it is no stand-in for a shape.
if (cache.id != kPreForbiddenCursorId) shownKey = key;
}
/// Whether the desktop paints the peer's cursor over the remote image.
@protected
bool get showsRemoteCursor {
final tag = ShowRemoteCursorState.tag(parent.target?.id ?? '');
return Get.isRegistered<RxBool>(tag: tag) &&
Get.find<RxBool>(tag: tag).value;
}
// Painted images kept: mobile always paints the cursor, a desktop only when it shows the
// remote cursor, and otherwise the native cursors show the shapes.
int get _imageLimit => isMobile || showsRemoteCursor ? kRecentShapes : 1;
// `_images` is in order of use. The image painted is kept, whoever's it is.
void _evictImages() {
var excess = _images.length - _imageLimit;
_images.removeWhere((id, shape) {
if (excess <= 0 || id == _id || identical(shape.item1, _image)) {
return false;
}
excess--;
shape.item1.dispose();
return true;
});
}
/// The core keeps every shape the peer sent, compressed; see `Session::cursor_shapes`.
@protected
Future<CursorShape?> fetchCursorShape(String id) {
final ffi = parent.target;
if (ffi == null) return Future.value(null);
return bind.sessionGetCursorShape(sessionId: ffi.sessionId, id: id);
}
// The shape the core could not give, not asked for again while it is the one in use. One
// is enough: only the shape in use is asked for on every frame, and the peer's ids must not
// accumulate here.
String? _unavailable;
// Counts session clears, so that a decode finishing after one keeps nothing.
int _generation = 0;
/// Decodes the shape in use again from the core, for a raster its native cursor lacks, for
/// painting it, or for a window a tab moved to.
void restorePixels(String id) {
if (id != _id || _unavailable == id || !_restoring.add(id)) return;
final generation = _generation;
() async {
try {
// A throw becomes an error the await hands over later, not during the build that may
// have asked: a failure tells the listeners.
final shape = await Future.sync(() => fetchCursorShape(id));
if (generation != _generation) {
return;
} else if (shape == null) {
_markUnavailable(generation, id);
debugPrint('Cursor $id is not kept by the core');
} else {
// Decoded even if the peer moved on: an animation comes back to it.
await updateCursorData(id, shape.hotx, shape.hoty, shape.width,
shape.height, shape.colors);
}
} catch (e) {
_markUnavailable(generation, id);
debugPrint('Failed to fetch cursor $id: $e');
} finally {
_restoring.remove(id);
}
}();
}
void _markUnavailable(int generation, String id) {
if (generation != _generation || id != _id) return;
_unavailable = id;
_updateCurData();
}
bool _updateCurData() {
final previous = _cache;
final cache = _cacheMap[_id];
// A shape not decoded yet leaves the one shown before in place until it is; one the core
// cannot give leaves none, so the default cursor shows, as for a shape never sent.
if (cache != null || _unavailable == _id) _cache = cache;
if (previous != null && !identical(previous, _cache)) {
_switchedAway(previous);
}
final tmp = _images.remove(_id);
if (tmp != null) {
_images[_id] = tmp;
_image = tmp.item1;
_hotx = tmp.item2;
_hoty = tmp.item3;
} else if (_unavailable == _id) {
_image = null;
}
_evictImages();
try {
// may throw exception, because the listener maybe already dispose
notifyListeners();
} catch (e) {
debugPrint('WARNING: updateCursorId $_id, without notifyListeners(). $e');
}
return tmp != null || cache != null;
}
updateCursorId(Map<String, dynamic> evt) {
if (!_updateCurData()) {
restorePixels(_id);
}
}
/// Update the cursor position.
updateCursorPosition(Map<String, dynamic> evt, String id) async {
if (!isConnIn2Secs()) {
gotMouseControl = false;
_lastPeerMouse = DateTime.now();
}
_x = double.parse(evt['x']);
_y = double.parse(evt['y']);
try {
RemoteCursorMovedState.find(id).value = true;
} catch (e) {
//
}
notifyListeners();
}
updateDisplayOrigin(double x, double y, {updateCursorPos = true}) {
_displayOriginX = x;
_displayOriginY = y;
if (updateCursorPos) {
_x = x + 1;
_y = y + 1;
parent.target?.inputModel.moveMouse(x, y);
}
parent.target?.canvasModel.resetOffset();
notifyListeners();
}
updateDisplayOriginWithCursor(
double x, double y, double xCursor, double yCursor) {
_displayOriginX = x;
_displayOriginY = y;
_x = xCursor;
_y = yCursor;
parent.target?.inputModel.moveMouse(x, y);
notifyListeners();
}
clear() {
_x = -10000;
_x = -10000;
_image = null;
_firstUpdateMouseTime = null;
gotMouseControl = true;
disposeImages();
_clearCache();
_cache = null;
_cacheMap.clear();
_nativeIds.clear();
_awaitingNative.clear();
_unavailable = null;
_generation++;
}
_clearCache() {
final keys = {...cachedKeys};
for (var k in keys) {
debugPrint("deleting cursor with key $k");
deleteCustomCursor(k);
}
_cacheKeys.clear();
shownKey = null;
resetSystemCursor();
}
trySetRemoteWindowCoords() {
Future.delayed(Duration.zero, () async {
_windowRect =
await InputModel.fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
});
}
clearRemoteWindowCoords() {
_windowRect = null;
_remoteWindowCoords.clear();
}
}
class QualityMonitorData {
String? speed;
String? fps;
String? delay;
String? targetBitrate;
String? codecFormat;
String? chroma;
}
class QualityMonitorModel with ChangeNotifier {
WeakReference<FFI> parent;
QualityMonitorModel(this.parent);
var _show = false;
final _data = QualityMonitorData();
bool get show => _show;
QualityMonitorData get data => _data;
// Only a WebRTC session on the web names its transport here: web has no
// session tab to show it on (the desktop tab's tooltip already does), and
// WebRTC is the one path that can be direct or TURN.
String? get webrtcTransport {
if (!isWeb) return null;
final ffiModel = parent.target?.ffiModel;
if (ffiModel == null) return null;
final streamType = ffiModel.cachedPeerData.streamType;
if (!streamType.startsWith('WebRTC')) return null;
return ffiModel.direct == false ? '$streamType (TURN)' : streamType;
}
checkShowQualityMonitor(SessionID sessionId) async {
final show = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: 'show-quality-monitor') ==
true;
if (_show != show) {
_show = show;
notifyListeners();
}
}
updateQualityStatus(Map<String, dynamic> evt) {
try {
if (evt.containsKey('speed') && (evt['speed'] as String).isNotEmpty) {
_data.speed = evt['speed'];
}
if (evt.containsKey('fps') && (evt['fps'] as String).isNotEmpty) {
final fps = jsonDecode(evt['fps']) as Map<String, dynamic>;
final pi = parent.target?.ffiModel.pi;
if (pi != null) {
final currentDisplay = pi.currentDisplay;
if (currentDisplay != kAllDisplayValue) {
final fps2 = fps[currentDisplay.toString()];
if (fps2 != null) {
_data.fps = fps2.toString();
}
} else if (fps.isNotEmpty) {
final fpsList = [];
for (var i = 0; i < pi.displays.length; i++) {
fpsList.add((fps[i.toString()] ?? 0).toString());
}
_data.fps = fpsList.join(' ');
}
} else {
_data.fps = null;
}
}
if (evt.containsKey('delay') && (evt['delay'] as String).isNotEmpty) {
_data.delay = evt['delay'];
}
if (evt.containsKey('target_bitrate') &&
(evt['target_bitrate'] as String).isNotEmpty) {
_data.targetBitrate = evt['target_bitrate'];
}
if (evt.containsKey('codec_format') &&
(evt['codec_format'] as String).isNotEmpty) {
_data.codecFormat = evt['codec_format'];
}
if (evt.containsKey('chroma') && (evt['chroma'] as String).isNotEmpty) {
_data.chroma = evt['chroma'];
}
notifyListeners();
} catch (e) {
//
}
}
}
class RecordingModel with ChangeNotifier {
WeakReference<FFI> parent;
RecordingModel(this.parent);
bool _start = false;
bool get start => _start;
toggle() async {
if (isIOS) return;
final sessionId = parent.target?.sessionId;
if (sessionId == null) return;
final pi = parent.target?.ffiModel.pi;
if (pi == null) return;
bool value = !_start;
if (value) {
await sessionRefreshVideo(sessionId, pi);
}
await bind.sessionRecordScreen(sessionId: sessionId, start: value);
}
updateStatus(bool status) {
_start = status;
notifyListeners();
}
}
class ElevationModel with ChangeNotifier {
WeakReference<FFI> parent;
ElevationModel(this.parent);
bool _running = false;
bool _canElevate = false;
bool get showRequestMenu => _canElevate && !_running;
onPeerInfo(PeerInfo pi) {
_canElevate = pi.platform == kPeerPlatformWindows && pi.sasEnabled == false;
_running = false;
}
onPortableServiceRunning(bool running) => _running = running;
}
// The index values of `ConnType` are same as rust protobuf.
enum ConnType {
defaultConn,
fileTransfer,
portForward,
rdp,
viewCamera,
terminal
}
/// Flutter state manager and data communication with the Rust core.
class FFI {
var id = '';
var version = '';
var connType = ConnType.defaultConn;
var closed = false;
/// dialogManager use late to ensure init after main page binding [globalKey]
late final dialogManager = OverlayDialogManager();
late final SessionID sessionId;
late final ImageModel imageModel; // session
late final FfiModel ffiModel; // session
late final CursorModel cursorModel; // session
late final CanvasModel canvasModel; // session
late final ServerModel serverModel; // global
late final ChatModel chatModel; // session
late final FileModel fileModel; // session
late final AbModel abModel; // global
late final GroupModel groupModel; // global
late final UserModel userModel; // global
late final PeerTabModel peerTabModel; // global
late final QualityMonitorModel qualityMonitorModel; // session
late final RecordingModel recordingModel; // session
late final InputModel inputModel; // session
late final ElevationModel elevationModel; // session
late final CmFileModel cmFileModel; // cm
late final TextureModel textureModel; //session
late final ShortcutModel shortcutModel; // session
late final Peers recentPeersModel; // global
late final Peers favoritePeersModel; // global
late final Peers lanPeersModel; // global
// Terminal model registry for multiple terminals
final Map<int, TerminalModel> _terminalModels = {};
// Getter for terminal models
Map<int, TerminalModel> get terminalModels => _terminalModels;
FFI(SessionID? sId) {
sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId);
imageModel = ImageModel(WeakReference(this));
ffiModel = FfiModel(WeakReference(this));
cursorModel = CursorModel(WeakReference(this));
canvasModel = CanvasModel(WeakReference(this));
serverModel = ServerModel(WeakReference(this));
chatModel = ChatModel(WeakReference(this));
fileModel = FileModel(WeakReference(this));
userModel = UserModel(WeakReference(this));
peerTabModel = PeerTabModel(WeakReference(this));
abModel = AbModel(WeakReference(this));
groupModel = GroupModel(WeakReference(this));
qualityMonitorModel = QualityMonitorModel(WeakReference(this));
recordingModel = RecordingModel(WeakReference(this));
inputModel = InputModel(WeakReference(this));
elevationModel = ElevationModel(WeakReference(this));
cmFileModel = CmFileModel(WeakReference(this));
textureModel = TextureModel(WeakReference(this));
shortcutModel = ShortcutModel(WeakReference(this));
recentPeersModel = Peers(
name: PeersModelName.recent,
loadEvent: LoadEvent.recent,
getInitPeers: null);
favoritePeersModel = Peers(
name: PeersModelName.favorite,
loadEvent: LoadEvent.favorite,
getInitPeers: null);
lanPeersModel = Peers(
name: PeersModelName.lan, loadEvent: LoadEvent.lan, getInitPeers: null);
}
/// Mobile reuse FFI
void mobileReset() {
ffiModel.resetRestartReconnectState();
ffiModel.waitForFirstImage.value = true;
ffiModel.isRefreshing = false;
ffiModel.waitForImageDialogShow.value = true;
ffiModel.waitForImageTimer?.cancel();
ffiModel.waitForImageTimer = null;
}
/// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward].
void start(
String id, {
bool isFileTransfer = false,
bool isViewCamera = false,
bool isPortForward = false,
bool isRdp = false,
bool isTerminal = false,
String? switchUuid,
String? password,
bool? isSharedPassword,
String? connToken,
bool? forceRelay,
int? tabWindowId,
int? display,
List<int>? displays,
}) {
closed = false;
if (isMobile) mobileReset();
assert(
(!(isPortForward && isViewCamera)) &&
(!(isViewCamera && isPortForward)) &&
(!(isPortForward && isFileTransfer)) &&
(!(isTerminal && isFileTransfer)) &&
(!(isTerminal && isViewCamera)) &&
(!(isTerminal && isPortForward)),
'more than one connect type');
if (isFileTransfer) {
connType = ConnType.fileTransfer;
} else if (isViewCamera) {
connType = ConnType.viewCamera;
} else if (isPortForward) {
connType = ConnType.portForward;
} else if (isTerminal) {
connType = ConnType.terminal;
} else {
chatModel.resetClientMode();
connType = ConnType.defaultConn;
canvasModel.id = id;
imageModel.id = id;
cursorModel.peerId = id;
}
final isNewPeer = tabWindowId == null;
// If tabWindowId != null, this session is a "tab -> window" one.
// Else this session is a new one.
if (isNewPeer) {
// ignore: unused_local_variable
final addRes = bind.sessionAddSync(
sessionId: sessionId,
id: id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isPortForward: isPortForward,
isRdp: isRdp,
isTerminal: isTerminal,
switchUuid: switchUuid ?? '',
forceRelay: forceRelay ?? false,
password: password ?? '',
isSharedPassword: isSharedPassword ?? false,
connToken: connToken,
);
} else if (display != null) {
if (displays == null) {
debugPrint(
'Unreachable, failed to add existed session to $id, the displays is null while display is $display');
return;
}
final addRes = bind.sessionAddExistedSync(
id: id,
sessionId: sessionId,
displays: Int32List.fromList(displays),
isViewCamera: isViewCamera);
if (addRes != '') {
debugPrint(
'Unreachable, failed to add existed session to $id, $addRes');
return;
}
ffiModel.pi.currentDisplay = display;
}
if (isDesktop && connType == ConnType.defaultConn) {
textureModel.updateCurrentDisplay(display ?? 0);
}
// FIXME: separate cameras displays or shift all indices.
if (isDesktop && connType == ConnType.viewCamera) {
// FIXME: currently the default 0 is not used.
textureModel.updateCurrentDisplay(display ?? 0);
}
if (isDesktop) {
inputModel.updateTrackpadSpeed();
}
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
// Though the stream is returned immediately, the stream may not be ready.
// Any operations that depend on the stream should be carefully handled.
late final Stream<EventToUI> stream;
if (isNewPeer || display == null || displays == null) {
stream = bind.sessionStart(sessionId: sessionId, id: id);
} else {
// We have to put displays in `sessionStart()` to make sure the stream is ready
// and then the displays' capturing requests can be sent.
stream = bind.sessionStartWithDisplays(
sessionId: sessionId, id: id, displays: Int32List.fromList(displays));
}
if (isWeb) {
platformFFI.setCursorDataCallback(ffiModel.handleCursorData);
platformFFI.setRgbaCallback((int display, Uint8List data) {
onEvent2UIRgba();
imageModel.onRgba(display, data);
});
platformFFI.setVideoFrameCallback((int display, ui.Image image,
bool Function() isCurrentSession) async {
if (!isCurrentSession()) {
image.dispose();
return;
}
await onEvent2UIRgba();
await imageModel.onImage(display, image, isCurrentSession);
});
this.id = id;
return;
}
final cb = ffiModel.startEventListener(sessionId, id);
imageModel.updateUserTextureRender();
final hasGpuTextureRender = bind.mainHasGpuTextureRender();
final SimpleWrapper<bool> isToNewWindowNotified = SimpleWrapper(false);
// Preserved for the rgba data.
stream.listen((message) {
if (closed) return;
if (tabWindowId != null && !isToNewWindowNotified.value) {
// Session is read to be moved to a new window.
// Get the cached data and handle the cached data.
Future.delayed(Duration.zero, () async {
final args = jsonEncode({'id': id, 'close': display == null});
final cachedData = await DesktopMultiWindow.invokeMethod(
tabWindowId, kWindowEventGetCachedSessionData, args);
if (cachedData == null) {
// unreachable
debugPrint('Unreachable, the cached data is empty.');
return;
}
final data = CachedPeerData.fromString(cachedData);
if (data == null) {
debugPrint('Unreachable, the cached data cannot be decoded.');
return;
}
ffiModel.setPermissions(data.permissions);
await ffiModel.handleCachedPeerData(data, id);
await sessionRefreshVideo(sessionId, ffiModel.pi);
await bind.sessionRequestNewDisplayInitMsgs(
sessionId: sessionId, display: ffiModel.pi.currentDisplay);
});
isToNewWindowNotified.value = true;
}
() async {
if (message is EventToUI_Event) {
if (message.field0 == "close") {
closed = true;
debugPrint('Exit session event loop');
return;
}
Map<String, dynamic>? event;
try {
event = json.decode(message.field0);
} catch (e) {
debugPrint('json.decode fail1(): $e, ${message.field0}');
}
if (event != null) {
await cb(event);
}
} else if (message is EventToUI_Rgba) {
final display = message.field0;
// Fetch the image buffer from rust codes.
final sz = platformFFI.getRgbaSize(sessionId, display);
if (sz == 0) {
platformFFI.nextRgba(sessionId, display);
return;
}
final rgba = platformFFI.getRgba(sessionId, display, sz);
if (rgba != null) {
onEvent2UIRgba();
await imageModel.onRgba(display, rgba);
} else {
platformFFI.nextRgba(sessionId, display);
}
} else if (message is EventToUI_Cursor) {
await ffiModel.handleCursorData(message.id, message.hotx,
message.hoty, message.width, message.height, message.colors);
} else if (message is EventToUI_Texture) {
final display = message.field0;
final gpuTexture = message.field1;
debugPrint(
"EventToUI_Texture display:$display, gpuTexture:$gpuTexture");
if (gpuTexture && !hasGpuTextureRender) {
debugPrint('the gpuTexture is not supported.');
return;
}
textureModel.setTextureType(display: display, gpuTexture: gpuTexture);
onEvent2UIRgba();
}
}();
});
// every instance will bind a stream
this.id = id;
}
Future<void> onEvent2UIRgba() async {
if (ffiModel.waitForImageDialogShow.isTrue) {
ffiModel.waitForImageDialogShow.value = false;
ffiModel.waitForImageTimer?.cancel();
clearWaitingForImage(dialogManager, sessionId);
}
if (ffiModel.waitForFirstImage.value == true) {
ffiModel.waitForFirstImage.value = false;
ffiModel.cancelPendingRestoreTimer();
ffiModel.resetRestartReconnectState();
dialogManager.dismissAll();
try {
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
} finally {
_applyPendingMonitorRestore();
}
}
}
void _applyPendingMonitorRestore() {
final restore = ffiModel.pendingMonitorRestore;
ffiModel._cancelPendingMonitorRestore();
if (restore == null || closed) return;
// The display list may have changed since the restore was queued.
final displays = ffiModel.pi.displays;
if ((restore == kAllDisplayValue && displays.isNotEmpty) ||
(restore >= 0 && restore < displays.length)) {
openMonitorInTheSameTab(restore, this, ffiModel.pi,
recordSelection: false, updateCursorPos: false);
}
}
/// Login with [password], choose if the client should [remember] it.
void login(String osUsername, String osPassword, SessionID sessionId,
String password, bool remember) {
bind.sessionLogin(
sessionId: sessionId,
osUsername: osUsername,
osPassword: osPassword,
password: password,
remember: remember);
}
void send2FA(SessionID sessionId, String code, bool trustThisDevice) {
bind.sessionSend2Fa(
sessionId: sessionId, code: code, trustThisDevice: trustThisDevice);
}
/// Close the remote session.
Future<void> close({bool closeSession = true}) async {
closed = true;
if (isWeb) {
platformFFI.clearVideoFrameCallback();
}
chatModel.close();
// Close all terminal models
for (final model in _terminalModels.values) {
model.dispose();
}
_terminalModels.clear();
if (imageModel.image != null && !isWebDesktop) {
await setCanvasConfig(
sessionId,
cursorModel.x,
cursorModel.y,
canvasModel.x,
canvasModel.y,
canvasModel.scale,
ffiModel.pi.currentDisplay);
}
imageModel.callbacksOnFirstImage.clear();
shortcutModel.clear();
await imageModel.update(null);
cursorModel.clear();
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);
}
debugPrint('model $id closed');
id = '';
}
void setMethodCallHandler(FMethod callback) {
platformFFI.setMethodCallHandler(callback);
}
Future<bool> invokeMethod(String method, [dynamic arguments]) async {
return await platformFFI.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
}
// Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
_terminalModels[terminalId] = model;
}
void unregisterTerminalModel(int terminalId) {
debugPrint('[FFI] Unregistering terminal model for terminal $terminalId');
_terminalModels.remove(terminalId);
}
void routeTerminalResponse(Map<String, dynamic> evt) {
final int terminalId = TerminalModel.getTerminalIdFromEvt(evt);
// Route to specific terminal model if it exists
final model = _terminalModels[terminalId];
if (model != null) {
model.handleTerminalResponse(evt);
}
}
}
const kInvalidResolutionValue = -1;
const kVirtualDisplayResolutionValue = 0;
class Display {
double x = 0;
double y = 0;
int width = 0;
int height = 0;
bool cursorEmbedded = false;
int originalWidth = kInvalidResolutionValue;
int originalHeight = kInvalidResolutionValue;
double _scale = 1.0;
double get scale => _scale > 1.0 ? _scale : 1.0;
Display() {
width = (isDesktop || isWebDesktop)
? kDesktopDefaultDisplayWidth
: kMobileDefaultDisplayWidth;
height = (isDesktop || isWebDesktop)
? kDesktopDefaultDisplayHeight
: kMobileDefaultDisplayHeight;
}
@override
bool operator ==(Object other) =>
other is Display &&
other.runtimeType == runtimeType &&
_innerEqual(other);
bool _innerEqual(Display other) =>
other.x == x &&
other.y == y &&
other.width == width &&
other.height == height &&
other.cursorEmbedded == cursorEmbedded;
bool get isOriginalResolutionSet =>
originalWidth != kInvalidResolutionValue &&
originalHeight != kInvalidResolutionValue;
bool get isVirtualDisplayResolution =>
originalWidth == kVirtualDisplayResolutionValue &&
originalHeight == kVirtualDisplayResolutionValue;
bool get isOriginalResolution =>
width == (originalWidth * scale).round() &&
height == (originalHeight * scale).round();
}
class Resolution {
int width = 0;
int height = 0;
Resolution(this.width, this.height);
@override
String toString() {
return 'Resolution($width,$height)';
}
}
class Features {
bool privacyMode = false;
}
const kInvalidDisplayIndex = -1;
class PeerInfo with ChangeNotifier {
String version = '';
String username = '';
String hostname = '';
String platform = '';
bool sasEnabled = false;
bool isSupportMultiUiSession = false;
int currentDisplay = 0;
int primaryDisplay = kInvalidDisplayIndex;
RxList<Display> displays = <Display>[].obs;
Features features = Features();
List<Resolution> resolutions = [];
Map<String, dynamic> platformAdditions = {};
RxInt displaysCount = 0.obs;
RxBool isSet = false.obs;
bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true;
bool get isInstalled =>
platform != kPeerPlatformWindows ||
platformAdditions[kPlatformAdditionsIsInstalled] == true;
List<int> get RustDeskVirtualDisplays => List<int>.from(
platformAdditions[kPlatformAdditionsRustDeskVirtualDisplays] ?? []);
int get amyuniVirtualDisplayCount =>
platformAdditions[kPlatformAdditionsAmyuniVirtualDisplays] ?? 0;
bool get isSupportMultiDisplay =>
(isDesktop || isWebDesktop) && isSupportMultiUiSession;
bool get forceTextureRender => currentDisplay == kAllDisplayValue;
bool get cursorEmbedded => tryGetDisplay()?.cursorEmbedded ?? false;
bool get isRustDeskIdd =>
platformAdditions[kPlatformAdditionsIddImpl] == 'rustdesk_idd';
bool get isAmyuniIdd =>
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
Display? tryGetDisplay({int? display}) {
if (displays.isEmpty) {
return null;
}
display ??= currentDisplay;
if (display == kAllDisplayValue) {
return displays[0];
} else {
if (display > 0 && display < displays.length) {
return displays[display];
} else {
return displays[0];
}
}
}
Display? tryGetDisplayIfNotAllDisplay({int? display}) {
if (displays.isEmpty) {
return null;
}
display ??= currentDisplay;
if (display == kAllDisplayValue) {
return null;
}
if (display >= 0 && display < displays.length) {
return displays[display];
} else {
return null;
}
}
List<Display> getCurDisplays() {
if (currentDisplay == kAllDisplayValue) {
return displays;
} else {
if (currentDisplay >= 0 && currentDisplay < displays.length) {
return [displays[currentDisplay]];
} else {
return [];
}
}
}
double scaleOfDisplay(int display) {
if (display >= 0 && display < displays.length) {
return displays[display].scale;
}
return 1.0;
}
Rect? getDisplayRect(int display) {
final d = tryGetDisplayIfNotAllDisplay(display: display);
if (d == null) return null;
return Rect.fromLTWH(d.x, d.y, d.width.toDouble(), d.height.toDouble());
}
}
const canvasKey = 'canvas';
Future<void> setCanvasConfig(
SessionID sessionId,
double xCursor,
double yCursor,
double xCanvas,
double yCanvas,
double scale,
int currentDisplay) async {
final p = <String, dynamic>{};
p['xCursor'] = xCursor;
p['yCursor'] = yCursor;
p['xCanvas'] = xCanvas;
p['yCanvas'] = yCanvas;
p['scale'] = scale;
p['currentDisplay'] = currentDisplay;
await bind.sessionSetFlutterOption(
sessionId: sessionId, k: canvasKey, v: jsonEncode(p));
}
Future<Map<String, dynamic>?> getCanvasConfig(SessionID sessionId) async {
if (!isWebDesktop) return null;
var p =
await bind.sessionGetFlutterOption(sessionId: sessionId, k: canvasKey);
if (p == null || p.isEmpty) return null;
try {
Map<String, dynamic> m = json.decode(p);
return m;
} catch (e) {
return null;
}
}
Future<void> initializeCursorAndCanvas(FFI ffi) async {
var p = await getCanvasConfig(ffi.sessionId);
int currentDisplay = 0;
if (p != null) {
currentDisplay = p['currentDisplay'];
}
if (p == null || currentDisplay != ffi.ffiModel.pi.currentDisplay) {
ffi.cursorModel.updateDisplayOrigin(
ffi.ffiModel.rect?.left ?? 0, ffi.ffiModel.rect?.top ?? 0);
return;
}
double xCursor = p['xCursor'];
double yCursor = p['yCursor'];
double xCanvas = p['xCanvas'];
double yCanvas = p['yCanvas'];
double scale = p['scale'];
ffi.cursorModel.updateDisplayOriginWithCursor(ffi.ffiModel.rect?.left ?? 0,
ffi.ffiModel.rect?.top ?? 0, xCursor, yCursor);
ffi.canvasModel.update(xCanvas, yCanvas, scale);
}
clearWaitingForImage(OverlayDialogManager? dialogManager, SessionID sessionId) {
dialogManager?.dismissByTag('$sessionId-waiting-for-image');
}