Files
rustdesk/flutter/lib/models/model.dart
T
RustDeskandClaude Opus 5.5 e9ddbd8f46 cursor: bound the decoded cursor caches on the Flutter client (#16304)
* cursor: name a shape by what it looks like, not by its handle

A cursor id was the platform's handle for the shape: HCURSOR on
Windows, the XFixes serial on X11. Apps mint a new handle for a shape
they have shown before (Chrome, Electron, Qt custom cursors), and an
X11 animated cursor gets one per frame, so one arrow arrived under
thousands of ids over a session. The controlled side cached and sent
it again under each, and the controller decoded, rendered and kept it
again under each, and grew by gigabytes over weeks of connection.

The controlled side now names a shape by an xxh3 hash of its size,
hotspot and pixels, taken once when a handle is first seen, before
the pixels are compressed. The per-connection send already sends a
shape once and afterwards only its id, so a shape now crosses once
however many handles it has. The id is opaque in the protocol, so
controllers of any version accept it. The DRM backend already named
shapes by content with FNV-1a; it uses the same hash now.

A peer before 1.5.0 still names shapes by handle, and the fix has to
work against every deployed peer. For those peers the controller
hashes each CursorData's compressed colors with its geometry: one
peer compresses the same pixels to the same bytes, so a shape seen
before is recognised without being decompressed. The UI gets that
shape once, under one id, and every later handle for it is passed on
as a cursor_id of that id. The map from the peer's ids is kept for the
connection, unbounded, since the peer sends a shape once and may
select any id it named again; an entry is two integers. From 1.5.0
the controller passes the peer's ids through unchanged, on the old
path.

The web core in flutter/web does the same in its own tree.

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

* cursor: hold a content id to what the web client can read

The web client decodes a u64 into a JS number and throws on a value
past 2^53, which drops the whole message. A full 64-bit xxh3 id is
almost always past it, so a web client connected to a peer on this
build would never have been given a cursor shape. DRM's ids were
already full width, so web clients lost the cursor on DRM peers too;
the controlled side now renames those as well.

The id keeps the hash's low 53 bits. Two of ten thousand distinct
shapes then collide with a chance of about one in two hundred million.

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

* cursor: hand the UI a shape's pixels as they are, not as text

A shape's pixels went to the Flutter UI as a JSON array of integers
inside the cursor_data event: the core serialised a 1 MiB shape into
up to 4 MiB of text on the receive loop, and the UI parsed that into a
million-element list and copied it into bytes, on the UI thread. The
event, text and all, was then kept for the session so that a tab
moved to another window could replay it, and a reconnect, on which the
peer sends every shape again, appended another copy of each.

The core now sends a shape as an EventToUI::Cursor variant carrying
Vec<u8>, which flutter_rust_bridge hands to Dart as a Uint8List, on
the same per-session stream as the events so that it keeps its order
with the cursor_id events around it. The web core calls onCursorData
with the Uint8Array, as it calls onRgba for a frame. The id stays
text, since the peer's ids can exceed Dart's int.

The replay keeps each shape's pixels once, by id, and encodes them as
base64 only when a tab moves. A shape arriving is also recorded as the
last one selected, since the replay goes in first-seen order; before,
a tab moved right after a new shape came back on the previous one.

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

* cursor: let a controller ask the peer for a shape again

A peer sends each shape once per connection and afterwards only its
id, so a controller must keep every shape it was sent for as long as
it is connected. The controlled side now answers
Misc::request_cursor_data with the CursorData of that id, from the
shapes it has sent, and only to a connection the cursor service
would send it to. A controller from 1.5.0 on can then bound what it
keeps and ask again for a shape it let go. None asks yet: the peer has
to have the answer before a controller can rely on it, and peers are
updated last.

The shapes are kept by content id, and every handle for a shape
shares its one message, where each handle used to keep its own copy.
They are dropped with the service's own cache.

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

* cursor: keep a shape as its ui.Image and nothing else

Each shape the UI had been sent stayed in four copies for the session:
the pixels for a tab move, an image package copy to resize from (on a
macOS or Linux controller of a Windows or Linux peer, a second one
decoded back from a PNG), the bytes handed to the native cursor, and
the ui.Image painted when the peer moves the pointer.

A shape now keeps only its ui.Image. The resize source and the native
bytes exist while a native cursor is being registered and are dropped
once it is; a shape shown again at a raster it was registered at needs
neither. For a raster it lacks, the pixels are read back from the
ui.Image, which returns them exactly as they came. A tab move reads
every shape back the same way, and the moved window decodes them as
it would have on arrival.

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

* cursor: pin that a released shape keeps neither its pixels nor its bytes

The shape in use once registered, the shapes not in use, and the
pixels read back for a moved tab are all dropped; the tests now say so,
so that a later change keeping one of them fails.

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

* cursor: keep one native cursor per shape, and each tab its own

A shape got a native cursor for every raster it was shown at, and all
of them stayed until the session ended, so every zoom or DPR change
added a native cursor for each shape in use. A shape now keeps the one
at its current raster; the one it replaces is deleted the next time a
cursor already registered is built, by when its successor has been
activated. A raster returned to before that takes its cursor back
instead of registering another under the same name.

The native cursors of a window's engine are shared by its tabs, and
the predefined cursors had one name in all of them, so a tab closing
deleted the default and forbidden cursors the other tabs still showed.
Names are now scoped to the tab's cursor model.

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

* cursor: keep the shapes compressed in the core, and only the one in use in the UI

The UI kept every shape as a ui.Image, one raw copy each, for as long
as the session lasted, because the peer sends a shape once and a
shape might have to be drawn again. The core receives the shapes
compressed, at a few hundredths of that, and now keeps them so:
Session::cursor_shapes holds each shape that decoded, as the peer sent
it, under the id the UI knows it by. The colors are copied, since the
message's may be a slice of a larger received buffer.

The UI keeps a ui.Image for the shape in use alone. A shape shown
again at a raster its native cursor has needs no pixels at all; for a
raster it lacks, for painting it, or for the window a tab moved to,
the UI asks the core through session_get_cursor_shape, which
decompresses and checks the shape again with decode_cursor_data, the
same size and decompression limits as on arrival. A shape the core
cannot give is not asked for again until the peer sends it. A tab
move now carries only the id in use, and nothing is read back from
the GPU.

The web core keeps the shapes the same way and gives them back through
getCursorShape.

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

* cursor: a decode that finishes after the session clears keeps nothing

A shape asked of the core while a tab closed was decoded after the
cursor model had been cleared and stored in it, where nothing disposed
of its ui.Image again; a shape whose cache failed to build left its
ui.Image undisposed as well. A clear now starts a new generation, and a
fetch or decode from an earlier one disposes what it made. A shape the
core gave but that did not decode is not asked for again on every
frame it is painted.

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

* cursor: keep a native cursor for the shape in use alone

Every shape the peer showed kept a native cursor for the session, each
a raster the size of the shape as displayed. A shape switched away
from now gives its native cursor up the way a replaced raster does:
deleted the next time a cursor already registered is built, taken back
if the shape returns first. A shape shown again after that is rebuilt
from the compressed copy the core keeps. The predefined cursors are
not the peer's shapes and stay registered.

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

* cursor: keep the native cursors of the 16 shapes used last

Keeping a native cursor for the shape in use alone rebuilt one on
nearly every switch, since the pointer moves among a few shapes: the
arrow, text, hand, four resize arrows, wait. The native cursors of the
peer's shapes are now kept in order of use, 16 of them, and the one
used longest ago gives its cursor up the way a replaced raster does.

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

* cursor: forget the native cursors a session clear deletes

The clear deleted every registered native cursor but kept their names,
so a cursor model used again, as the mobile client's is, took each
name for registered and showed a cursor that no longer existed.

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

* cursor: hold a native cursor to 512 pixels a side

A native cursor could be registered up to 1024 pixels a side, four
MiB of pixels each, which with sixteen kept made 64 MiB at worst. The
largest cursor a system shows is 256 pixels (Windows' largest pointer
size), so a raster twice that is shown no larger: the cursor is drawn
smaller rather than rejected, and sixteen kept come to 16 MiB at most.

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

* cursor: a shape switched away from while it decodes is not marked lost

A shape asked of the core was taken for undecodable when it was no
longer kept once its decode finished, but a shape the peer switched
away from in the meantime is let go on purpose, and marking it made
every later request for it be skipped: its native cursor stayed
deferred and its painted cursor missing for the rest of the session.
The decode now reports whether it decoded, and only a failure marks
the shape.

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

* flutter: leave two untouched signatures as they were

dart format had rewrapped registerEventHandler and sessionSetCommon
next to the cursor additions.

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

* cursor: read a shape's pixels from where its view starts

The pixels used to be copied out of the JSON event into a fresh list,
so the list's buffer began at its first pixel. Now they come straight
from the core, native or web, as whatever Uint8List it hands over, and
img2's fromBytes takes the buffer, not the list, so a view into a
larger buffer would have been read from the buffer's start. Neither
core gives a view today; this stops depending on that.

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

* cursor: a DRM cache reset keeps the shape it was making room for

The shape being sent was filed in CURSOR_SHAPES before the DRM ceiling
cleared it with everything else, so a controller asking for the shape
on screen got nothing until the next new shape.

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

* cursor: delete a replaced native cursor at the next build that shows one

A replaced native cursor was deleted only by a build of a cursor
already registered, so a run of new shapes, each shown once, left
every replaced cursor registered: 16 on the books, all of them in the
system. Now every build that shows a cursor deletes the ones replaced
before it, right before it registers or takes one back; what replaced
them was activated in an earlier frame, so they are off screen. A
build that has to ask for pixels shows nothing new and deletes
nothing, so a raster returned to right after being replaced still
takes its cursor back; one returned to later registers again.

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

* cursor: a shape that does not decode leaves no handle and no copy

CursorDedupe named a handle as it hashed the shape, before the shape
was checked, so a peer sending shapes that fail the checks under new
handles grew the map with nothing the UI could ever select, and such
a shape took over the handle of one that had decoded. The handle is
now named once its shape decoded, or was shown before.

The flutter build also copied a shape's compressed colors out of the
message before the checks, so a message with a small size and huge
colors was copied whole before being rejected. The copy is made once
the shape decoded; until then the message's own bytes are held.

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

* cursor: remember the shape the core lacks only while it is in use

Every id the core could not give was kept in a set for the session,
so a peer sending cursor_id after cursor_id of shapes it never sent
grew it by a string each, at a few bytes of traffic per entry. The
mark exists so that painting does not ask for the shape in use on
every frame, and the shape in use is one, so one mark is kept: the
peer selecting another shape forgets it, and selecting a lost shape
again asks once more.

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

* cursor: forget the shape the core lacks once the peer selects another

The mark was replaced only by another shape the core lacked, so a
lost shape selected again after a known one was still not asked for.
Selecting any other shape now clears it.

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

* cursor: leave the DRM cursor's own id as it was

The wire id is cursor_content_id whatever the platform, set in
run_cursor over the shape's pixels, so the id the DRM reader derives
for itself only tells one hardware cursor from the next and keys the
service's cache. Which hash it uses does not reach a controller; the
change to xxh3 and the dependency it brought to scrap go.

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

* cursor: say the 1.5.0 gate means the release

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

* cursor: say why the peer answers request_cursor_data with what it holds

No controller sends it yet, and a review asked for it to go because a
cache reset on the peer could leave a request unanswered. A shape the
peer's cache dropped is rebuilt, and indexed again, the next time it
is shown, before any connection names it by id, so the shape a
controller has just been told to show is always there to give; that
is the contract a future bounded controller relies on.

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

* cursor: drop the tests nothing can fail

a_handle_is_named_once_its_shape_is_shown dates from when `name`
wrote the handle map. `name` is pure now, so its last assertion
cannot fail, and the others repeat two neighbours.
every_handle_the_peer_named_stays_for_the_connection fails only for
a bound added on purpose, which the struct's doc rules out. The Dart
arrival test is covered by the first test of its file.

The kept-shape test now asserts what `cursor_shape` is for, a copy
rather than a view of the received buffer, in place of the unknown
fields it drops. The content-id test moves out from between two
`use` lines.

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

* cursor: let the kept-shape tests run without the flutter feature

CI's cargo test builds the default features, and the two functions
under test are pure, so they compile for every test build and the
module loses its feature gate.

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

* cursor: keep native cursors for every frame of an animated cursor

An animated cursor is a shape per frame, 18 for the Windows busy cursor
and 23 for KDE's wait cursor. With 16 native cursors a cycle longer than
that missed on every frame: fetched from the core, decoded, registered
again and the replaced cursor deleted, about thirty times a second for
as long as the peer was busy. 64 holds two such animations and the
everyday set besides.

The tests settle in 10 ms instead of 100 ms, so the ones that fill the
limit stay quick.

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

* cursor: release only the shapes that can hold pixels

Selecting a cursor swept every shape of the session to release pixels,
though only two can hold any: the shape selected before, and a shape
that finished decoding after the peer moved on. Release those, so a
cursor_id costs the same however many shapes the peer has shown.

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

* cursor: show the native cursor limit holds two animations and the everyday set

64 is a working set, not the longest animation: KDE's wait and progress
cursors are 23 frames each, and a session that meets both plus a dozen
static shapes must keep them all, or every turn rebuilds.

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

* cursor: let the shape in use keep its pixels for a new raster

Dropping them once the native cursor was registered meant every scale
change, zooming with the cursor zoomed or crossing to a display of
another scale, showed the system arrow while the core gave the shape
back and it decoded again. The shape in use now keeps them and makes the
new raster at once; a shape switched away from still keeps none.

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

* cursor: never fall back to the default cursor on a switch

A switch to a shape without its image, or without a native cursor at
its raster, showed the default arrow until the core gave the shape back
and it decoded: the painted cursor on every switch back, the native one
after an eviction or while a new shape decoded.

- The shape shown before stays, image, hotspot and native cursor, until
  the one in use is ready.
- Where the cursor is painted, on mobile or with the remote cursor
  shown, the images of the 64 shapes used last are kept, like the native
  cursors, so an animation does not decode a frame per turn. Elsewhere
  only the shape in use keeps one.
- A shape that finishes decoding after the peer moved on is kept with
  them instead of dropped, so a fast animation fills them at all.

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

* cursor: name the dedupe's content-id set for what it holds

`shown` read as the shape on screen. It is the shapes decoded and given
to the UI, keyed by content id, so a shape arriving under a new handle
is looked up once instead of scanning the handle map: `decoded`,
`has_decoded`, and `record` for the call that fills both maps. The web
mirror is renamed the same way.

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

* cursor: keep native cursors for the session, as before

Evicting native cursors and deleting a shape's replaced raster made
Windows leak more than master. The engine's deleteCustomCursor/windows
frees the HCURSOR with DeleteObject, which does not take a cursor, so a
delete frees nothing and each cursor made again for a raster, or for a
shape shown once more, leaks another handle. Keeping every raster of
every shape until the session is cleared creates each one once, as
master does.

Fixing the engine instead is not cheap: x64 runs our fork on Flutter
3.24, where the fix is one more patch to rebuild and carry across every
upgrade; arm64 runs the stock engine of a newer Flutter with the same
code, where it means porting the fork and publishing an arm64 engine
too, or waiting for an upstream fix to reach stable. Content ids keep
the count to the shapes the peer really shows.

The painted images, the compressed shapes in the core, and a switch
showing the cursor shown before are unchanged.

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

* cursor: record the Windows measurement behind keeping native cursors

A measurement on Windows confirmed what the engine source implied: of
500 cursors made by CreateIconIndirect, DeleteObject freed none and
left 500 USER objects alive, while DestroyCursor and DestroyIcon freed
all 500. The comment now says so, that native cursors have no LRU for
that reason only, and that one can come back once both engines destroy
cursors.

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

* cursor: look a native cursor up by the raster asked for

The key ended in the raster made last. A shape switched away from keeps
no pixels, so its raster stays where it was, and going back to a raster
it was shown at before looked up the wrong key: A at 1.0, A at 0.5, then
B, then A at 1.0 again missed the 32x32 cursor that was still there,
fetched A from the core, and showed B meanwhile. With native cursors
kept for every raster, the key now ends in the raster asked for; with
pixels, that is the raster made, as before.

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

* cursor: let a shape keep its pixels until a native cursor holds them

A shape switched away from, or decoded after the peer moved on, let its
pixels go whether or not it had a native cursor, and a native cursor is
made only for the shape in use. A shape whose restore kept finishing
after the next switch, as an animation's frames can, was fetched and
decoded again every time it came back and never got a native cursor.

A shape without one now keeps its pixels until one is made, at most
kRecentShapes of them, the one waiting longest let go first. Mobile
only paints the cursor, so it lets them go as before.

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

* proto: keep cursor ids as strings in JS

A Windows peer before 1.5.0 names a cursor by its handle, sign-extended
above 2^53 when its top bit is set. The web client decodes u64 fields
into JS numbers, and ts-proto throws on a value a number cannot hold,
so each such cursor_data or cursor_id was dropped whole and the web
cursor stayed on the shape before. [jstype = JS_STRING] makes ts-proto
generate these two fields as strings; the wire format, the Rust code
generated and every other client are unchanged.

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

* cursor: wait for a native cursor at the raster asked for, not any raster

A shape let its pixels go once it had a native cursor at some raster.
After a zoom, or on a display of another scale, an animation's frames
had native cursors at the old raster only; each was fetched again at
the new one, and a restore that finished after the next switch let its
pixels go at once, so the frames never got a native cursor at the new
raster and the cursor stayed on the one shown before. A lookup that
finds no native cursor at the raster asked for now counts the shape as
waiting for one, so a late restore keeps its pixels until it has one.

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

* cursor: count a shape held again when its asked raster has a native cursor

A lookup that missed took the shape out of _nativeIds, but one that hit
did not put it back. A shape asked at a new raster, then at one it has a
native cursor for, still counted as waiting: a restore finishing after
the switch away kept its pixels in _awaitingNative instead of letting
them go. The set now says whether the raster asked last has a native
cursor, either way.

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

* Revert "cursor: count a shape held again when its asked raster has a native cursor"

This reverts commit 7cefbfc7a.

_nativeIds is meant as "no raster of this shape is still missing its
native cursor", not "the raster asked last has one". A shape asked at a
new raster whose restore is on its way, then shown at a raster it has a
cursor for, still waits for the new one; counting it held let the late
restore's pixels go, so going back to the new raster fetched it again,
and with an animation's frames and a scale that keeps changing, the
loop 1e4388771 fixed could come back. Keeping the pixels costs at most
a waiting slot, within the 64. A test now locks this: a raster still
missing keeps a late restore whatever was shown meanwhile.

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

* cursor: reuse a shape sent before without compressing it again

A handle not seen before sent the service through the whole path:
capture the pixels, name them by content, compress them, and only then
find the shape already in CURSOR_SHAPES and drop what it compressed. An
app that mints a new handle for the same cursor, as Chrome and Electron
do, paid a compress per handle, a few ms for an enlarged cursor. The
content id is now looked up first, and only a new shape is compressed.

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

* cursor: bound the handles the service files, and the shapes with them

A platform may mint a new handle each time it shows a shape, as Chrome
and Electron do, so cached_cursor_data grew by an entry per handle for
the life of the service, even when every one named the same shape; and
the compressed shapes in CURSOR_SHAPES were bounded by nothing but the
handles. Past 4096 handles, or 32 MiB of compressed shapes however few
the handles, both start over together, keeping the shape being sent: a
handle shown again is captured and named again. On every platform; the
DRM build's own 64-entry ceiling is unchanged and comes first there.

CURSOR_SHAPES keeps the bytes it holds beside the shapes, cleared with
them, so every path that clears it resets the count.

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

* cursor: let the macOS seed report a change, and the content name the cursor

macOS named a cursor by a fingerprint of its size, hotspot and two
pixels near the hotspot. It collides between standard cursors: the open
and the closed hand give one value, and the arrows with a badge (copy,
not allowed, contextual menu, disappearing item) another, so a switch
between them was never sent, or a cached shape of the other was.
CGSCurrentCursorSeed already says when the cursor changed; get_cursor
now reports the seed, and the shape is named by its content, as on
every platform. The fingerprint is gone.

get_cursor_data checks the seed before and after the capture. The seed
was taken before capturing, so a capture that found the cursor changed
again lost the change; it now leaves it for the next poll.

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

* cursor: file no macOS seeds, and send nothing for the shape already shown

A macOS seed marks a change and never comes back, so filing it as a
handle only filled the map; the byte ceiling still bounds the shapes
there. A new handle or seed for the shape already shown, which a seed
change without a new shape or an app minting handles gives, sent every
connection a cursor_id for what it already showed; a shape is one
message however many handles name it, so the same message is not sent
again.

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

* cursor: capture the macOS cursor shown without checking the seed around it

Every macOS seed change is now captured, pixel by pixel, and a seed
that moved during that capture failed it; the service then logged an
error and slept a second, so the controller's cursor lagged while it
changed. The check guarded nothing: the shape is named by its content,
not the seed, and a change after get_cursor read the seed moves it on,
so the next poll captures it anyway. Resetting the seed on a failed
check was not needed for the same reason.

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

* cursor: fall back to the default cursor for a shape the core cannot give

The shape shown before stays while the one in use decodes, so a switch
does not flash the default cursor. It also stayed once the shape in use
was known to be lost (rejected by the core, or not decoding): the
controller kept drawing, say, an I-beam for a pointer it never got,
until the peer changed shape. Both the native and the painted cursor
now leave the default one, as master did for the native cursor. A shape
that does not decode is marked in updateCursorData, whether it was sent
or fetched again, so both paths agree.

The client also selects a shape it rejects as it arrives. It showed the
shape before until the peer selected the rejected one again, and the
default cursor from then on; it now looks the same both times. Sciter
ignores an id it has no shape for, as it did.

The default and forbidden cursors are made with the model. They were
made the first time a build asked for one, and their decode lands
later with nothing to draw again, so that first build showed the shape
before in their place.

A fetch failing after the session was cleared marks nothing in the new
one, and a fetch that throws at once tells the listeners after the
build that asked for it, not during it.

The forbidden cursor, shown while input is off, became the cursor shown
last, so a raster made after input came back showed it until the
shape's pixels were fetched, or for good if they never came. Only a
shape stands in for a shape now; CursorModel.shown keeps the rule for
both builders.

_checkView asks for a single registration again: every native cursor
is made once per session, and one made twice leaks an HCURSOR on
Windows.

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

* cursor: name a handle by the shape rejected under it, not the one before

A shape the client rejects is selected as it arrives, so that it shows
the default cursor then as it does later. Under a handle reused for it,
the dedupe still named the shape the handle had brought before, and
selecting that switched the pointer to an old shape: A under handle 7,
then B under 8, then a rejected C under 7 showed A in place of B, and
again whenever the peer selected 7.

The handle now names the rejected content, which is never marked
decoded, since only a shape not decoded before reaches the decode: the
UI has no shape under it and shows the default cursor, then and when
the handle is selected again. The shape the handle named before stays
kept for the other handles naming it, and a handle that brings it again
names it again.

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

* cursor: file no handle for a shape that is rejected under it

A shape rejected under a handle named the handle by the rejected
content, so that a known handle does not show the shape it brought
before. A handle never seen was filed the same way, so a peer sending
shapes that do not decode under new handles grew the map with each,
where a rejected shape used to leave nothing. Only a known handle is
renamed now; one never seen is not filed and is passed on as unknown,
which the UI shows as the default cursor.

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

* cursor: say what Future.sync defers, and give the default cursor time on a slow runner

The comment on the fetch in restorePixels read as if the fetch were put
off; it runs at once, and Future.sync only turns its throw into an error
the await hands over later, which is what keeps the notify out of the
build.

cursor_default_test gave the default cursor 100 ms to decode, which a
slow runner may miss. It cannot wait for the cursor itself, since asking
for it would make it; it now waits 500 ms.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:09:46 +08:00

4655 lines
155 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/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 {
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.
final last = lastUserDisplay;
pendingMonitorRestore = (!isCache &&
last != null &&
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 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));
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();
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');
}