Files
rustdesk/flutter/lib/desktop/pages/desktop_setting_page.dart
RustDesk 3fc11c0f81 port forward shared conn (#16062)
* hbb_common: bump to the port-forward-mux proto

Also latches PortForward.multiplex into login_scope_digest, which
destructures PortForward's fields exhaustively by design (a new field
must be latched or deliberately ignored to compile).

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

* port_forward_mux: window accounting and channel frame builders

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

* port_forward_mux: fix RecvWindow counter overflow on long transfers

Replace cumulative accounting (granted/received) with remaining credit
tracking to prevent u32 overflow after 4 GiB of data on a single channel.
Wire behavior is identical, but the fix allows large file transfers
without mid-stream channel closure.

Add regression test for 8 GiB transfer to verify fix.

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

* port_forward_mux: credit-windowed relay halves and channel coordinator

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

* server: PortForwardMux channel table and per-channel tasks

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

* server: multiplexed port-forward connections stay in the protobuf loop

Wire PortForwardMux into Connection: take the multiplexed path at login
when the controller sets PortForward.multiplex, route
PortForwardChannel frames to it from on_message, sweep the channel
table's targets after open/close, and clean it up on connection close.

Introduce is_port_forward() (socket-based or multiplexed) and use it
at the four sites that classify the connection, so a multiplexed
connection stays in the message loop, gets TestDelay keepalives, and
reports features.port_forward_mux in PeerInfo. The three sites that
break into the raw pipe loop or gate the keepalive still check
port_forward_socket specifically, since a multiplexed connection must
not take that path.

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

* cm: update a port-forward row's targets as tunnel channels come and go

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

* port_forward_mux: controller tunnel with a single-writer stream loop

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

* port_forward_mux: publish Muxed before spawning the tunnel loop

Publishing after spawn let a loop that dies immediately reset the state
first, so the later publish pinned it at Muxed with a dead handle
forever. Also adds a test pinning open-before-data ordering across many
concurrently opened channels, and drops an unused Clone derive.

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

* port forward: share one multiplexed tunnel across a window's listeners

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

* port forward: fix round 1 review findings

Drop the mux default-false assignment now that definite-assignment proves
every path that reads it has set it; the enable-port-forward-mux config
commit picks up the missing attribution trailers; the default-on test
pins the enable- prefix itself rather than option2bool's weaker fallback.

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

* port_forward_mux: end-to-end tests over a loopback tunnel

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

* port_forward_mux: fix bulk test's premature half-close, pin the half-close limitation

many_channels_echo_concurrently_and_a_bulk_one_does_not_starve_them dropped
its bulk write half as soon as writing finished, which shuts down the write
side of the socket and, by design (see the design doc's TCP half-close
non-goal; today's run_forward does the same), ends the whole channel. Keep
the write half alive until the reader is done so the test measures
starvation, not half-close. Add a_local_half_close_ends_the_whole_channel to
pin that limitation in code.

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

* port_forward_mux: cap send credit and other final review fixes

Fix 1 (critical): clamp SendCredit to MAX_SEND_CREDIT (= CHANNEL_WINDOW)
in both new() and add(), so a peer with tunnel permission can no longer
advertise an unbounded window and force the controlled side's unbounded
FrameSink::Direct sink to buffer unlimited target data per channel.

Fix 2: rename the "starve" test to many_channels_echo_concurrently and
drop its (untrue) starvation claim, since it opens every channel before
the bulk transfer starts. Add a_channel_opened_during_a_bulk_transfer_
is_served_promptly, which opens the small channel while the bulk one is
demonstrably mid-flight.

Fix 3: only look up the tunnel permission for `open` frames in the
PortForwardChannel arm of on_message, instead of once per data frame.

Fix 4: two rustfmt deviations in connection.rs (matches! wrapping and a
tuple literal), fixed by hand without a blanket cargo fmt run.

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

* port_forward_mux: report a refused channel's reason as an error dialog

The controlled side already answers a refused port-forward channel with
opened { success: false, message }; on the multiplexed path TunnelHandle::
on_frame only logged that message at debug and closed the channel, so the
user saw a closed connection with no explanation, worst on the RDP path
where only the RDP client's own error remained. on_frame now returns the
message the window should show, deduplicated per distinct reason (capped
at MAX_REPORTED_OPEN_ERRORS) so one page load's dozen refused connections
surface one dialog per reason instead of a dozen.

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

* Use on_error for refused-channel dialog in tunnel_loop

Redirect the refused-channel error through the standard on_error path
instead of calling msgbox directly, for consistency with other errors
in the port-forward flow.

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

* port forward: apply the whole-branch review

Correctness:
- listen(): the Legacy arm is merged with the Claimed arm. On its own it
  ignored outcome.local_eof, so a client that hung up during login still
  got a target connect, an audit record and a CM row on the controlled
  side, and ignored outcome.mux, so a peer upgraded while a legacy window
  stayed open answered as a tunnel while the controller went raw.
- Refusal dialogs are deduplicated per quiet spell (10 s) rather than per
  tunnel lifetime; the lifetime set went silent for the rest of a
  long-lived window after the first burst.
- Android's CM listener handles UpdatePortForward; it fell into `_ => {}`.
- relay_socket_to_tunnel reads into one scratch buffer per channel and
  sends an exact-size copy. A frame owning its 64 KiB read allocation
  pinned it until sent, once per byte on interactive traffic.

Consistency and cleanups:
- The controlled side's refusal text is the raw pipe's wording, RDP
  substitution included.
- connection.rs: the PortForwardChannel arm is a one-line hook, the CM
  label is pushed from the 1 s tick alone, and the unreachable inner.tx
  fall-through is gone.
- The Ready enum is removed; wait_ready() returns Option<Claim>.
- SendCredit::add wakes with notify_one alone.
- on_ui_command() replaces the two ui_receiver handlers in listen().
- TunnelHandle is no longer re-exported (unused-import warning).

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

* port forward: a legacy window stays legacy until it is reopened

Review: the merged `Claimed | Legacy` arm gave a legacy window a hot
transition to a tunnel — every accept re-negotiated, and a peer upgraded
while the window stayed open was promoted underneath live connections.
The product does not need a mode switch inside a window's lifetime, and
the transition was extra state-machine surface for nothing: reopening
the window picks up an upgraded peer.

The two arms are separate again. `Claimed` negotiates once and the
peer's answer fixes the window's mode. `Legacy` logs in for every accept
as before, asks for no tunnel — `LoginConfigHandler::port_forward_mux`
carries the request per login, so the raw pipe never has to talk to a
peer that thinks it agreed to multiplex — and ignores what the peer
reports. Both arms keep skipping a local socket that hung up during
login.

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

* hbb_common: bump to main with rustdesk/hbb_common#594 merged

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

* server: admit only INITIAL_WINDOW on a channel before opened

The demultiplexer accepted CHANNEL_WINDOW into a pending channel's
unbounded queue, four times the bound the channel task enforces once
it polls. The window now starts at INITIAL_WINDOW and is widened right
before `opened` advertises the rest.

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

* port_forward_mux: a tunnel ends when its window drops the Tunnel

The loop held its own handle and state sender, so once the window
closed nothing was left to stop it: it kept answering TestDelay and the
peer connection, CM row included, lived on until the peer went away.
`Tunnel` now owns a watch sender nobody sends on; the loop's receiver
errors when the last `Tunnel` drops, and the loop ends.

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

* port forward: one tunnel per mapping, bound to the authenticated target

The login latches `PortForward.host`/`port` into the session scope and
approval is shown that target, but a window-wide tunnel let any later
`open` name another target with only `enable-tunnel` rechecked. A
tunnel now belongs to one listener and serves the one target its login
authenticated: the controlled side refuses an `open` for any other
target, and a window with several targets uses one connection each,
approved on its own.

With one owner per tunnel the claim needs no waiters: `Establishing`,
`Claim::Wait` and `wait_ready` go, and `try_claim` becomes a plain
read. The CM label that followed a tunnel's targets goes with them; a
row shows its mapping's target, as before.

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

* port forward: the legacy comment names the mapping, not the window

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

* port_forward_mux: a window violation drops the channel on the spot

Both demultiplexers only queued a `Violation` and left the entry until
the channel task woke and exited, so a peer that kept sending past the
window queued one more entry per frame in the meantime, bounded by
nothing. The entry now goes the moment `accept` fails; later frames for
that id are unknown-channel noise.

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

* port forward: the login's target travels with the accept, not the handler

`listen()` wrote `lc.port_forward` (and, on this branch, `port_forward_mux`)
into the window's shared `LoginConfigHandler` before connecting, and
`create_login_msg` read them back only when the peer's `Hash` arrived.
Two mappings logging in at the same time could therefore swap targets:
on master that bridged a local socket to the wrong target, and with a
tunnel bound to its login's target it also left the mapping refusing
every later accept until it was recreated.

The target is now a `PortForward` carried by the interface clone that
handles one accept, passed explicitly down to `create_login_msg`; the
handler no longer has a field to race on. No lock spans the login.

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

* port_forward_mux: pin permission revocation and whole-tunnel failure in tests

Both already hold; the review asked for them to be stated. `enable-tunnel`
turned off mid-session refuses the next `open` while the live channel
keeps relaying, and a dead tunnel ends every channel on it together,
after which the next accept establishes again on the same `Tunnel`.

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

* port forward: the legacy comment names re-adding the mapping only

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

* port forward: the raw pipe runs the code it always ran

The multiplexed login had replaced `connect_and_login`, so a mapping
with the setting off, a peer without the feature, or a listener latched
`Legacy` still went through the tunnel's state machine, the capped
pre-read and the changed local-EOF rule. Feature off now means the old
code: `listen()` keeps its accept arm and `connect_and_login` as they
were, and the tunnel is a branch taken only when the setting is on, in
`establish_tunnel` with its own `connect_and_login_mux`. The one line
the raw path does differently is the target riding with the accept's
interface clone instead of the shared handler.

`get_port_forward_mux_enabled` had one caller and moves in here, so
`common.rs` is untouched.

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

* port forward: a UI login answers the challenge its own connection was given

`handle_login_from_ui` hashed the typed password against `lc.hash`, the
window's shared handler field, and the window's password prompt is
broadcast to every listener. With two mappings both waiting on that
prompt, the `Hash` that arrived last had overwritten the other's, so
one of the two answered the wrong challenge and failed to log in.
Master shares the same state and broadcasts the same way.

The `Hash` is now a parameter of the login; `Session` keeps it beside
the connection it belongs to, and the per-accept clone that
`with_port_forward` makes gets a slot of its own. `lc.hash` stays for
`handle_peer_info`, which only needs the salt, and that is per peer.

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

* port forward: a mapping without its hash waits for it before answering the prompt

The window's password prompt is broadcast to every mapping, and can
reach one whose own connection has not received its `Hash` yet. That
mapping used to answer anyway, with a digest over an empty challenge:
the peer refused it and counted a failed attempt, and the empty-salt
result was written into the shared `lc.password`, where the mapping that
prompted had just stored the right one and the next `handle_peer_info`
would persist whatever was there.

The connection's challenge is now `Option<Hash>`, `None` until
`handle_hash` runs, and `handle_login_from_ui` sends nothing without it.
The mapping that prompted stores the salted password in the shared
handler, and the waiting one logs in with that against its own challenge
when its `Hash` arrives, without prompting again.

Test: A answers its prompt, the same broadcast reaches B before its
hash, B sends nothing, B's hash arrives and its login carries B's
challenge and B's target with no dialog. It runs the real `handle_hash`
for B.

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

* port forward: the tunnel's login is the raw pipe's, asked for by a window flag

Master's fix for the shared login slots (#16069) keeps the target and
the challenge in the window's `LoginConfigHandler` and serializes the
mappings' logins with a turn lock, all inside `port_forward.rs`. This
branch had carried a broader shape of the same fix, a `with_port_forward`
on `Interface` and the target and `Hash` as parameters through the login
functions, which every caller had to follow. That is gone: `Interface`,
`Session`, `create_login_msg`, `send_login`, `handle_hash` and
`handle_login_from_ui` are as on master.

What the tunnel needs on top is one bit in the login, `multiplex`. It is
a window flag beside `port_forward` in the handler, set once in `io_loop`
before the window's mappings start, so an accept's claim and its login
read the same value; the setting takes effect for windows opened after
it changes. `connect_and_login_mux` is now master's `connect_and_login`
with the tunnel's three differences and the same `hash_arrived` and
`login_from_ui` calls. The raw pipe is master's, line for line.

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

* hbb_common: bump to main with rustdesk/hbb_common#595 merged

840c8ec..f94e3fe is that one merge: the five local settings custom
clients could not preset.

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

* port forward: the off switch gets a checkbox in Settings → General

`enable-port-forward-mux` was readable only by editing the config file.
It is a local setting of the controlling side, so it sits with the other
outgoing ones, after "Open connection in new tab", with a tooltip saying
what it does.

The two new keys are translated in every language. The three that the
mobile file manager added, "Export", "Export Logs" and "Import Folder",
were empty everywhere but five languages; they are filled in too, and
Korean's "xdp-portal-unavailable" with them.

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

* Urdu: fill the backlog of empty and missing translations

ur.rs had fallen behind: 104 keys carried an empty value and 35 keys the
other languages have were absent altogether. Both are filled in, the
missing ones in the order template.rs lists them.

Eight entries stay empty on purpose. They are keys that only ur.rs still
carries, absent from template.rs and from every other language, so their
English source cannot be recovered and nothing reads them:
remember_account_tip, os_account_desk_tip, another_user_login_*_tip,
xorg_not_found_*_tip and no_desktop_*_tip. Twelve more dead keys keep
the values they have; removing either group is a separate decision.

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

* Urdu: drop the keys template.rs no longer lists

The twenty keys removed here are absent from template.rs and from every
other language file; ur.rs was the only one still carrying them, eight
of them with no value at all. They are leftovers of features that are
gone: the plugin menu, the OS-account login prompts, the Xorg and
no-desktop errors.

ur.rs now holds exactly the template's key set, all of it translated.

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

* port forward: closing the tunnel reaches channels parked on their socket

A channel whose far end neither reads nor writes has both relays parked
on the socket, not on the inbound queue, so `close_all` dropping the
queue's sender woke neither: the socket and both tasks lived on until
the far end hung up. Both sides now hold a per-tunnel teardown signal
that `run_channel` selects on beside its own cancel, and `close_all`
sends it after clearing the map.

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

* port forward: a mapping latched to the raw pipe logs in without asking for the tunnel

The login copied the window's `port_forward_mux` into `multiplex`, so a
mapping that had latched to the raw pipe on an old peer kept asking for
the tunnel. Once that peer was upgraded it answered with a tunnel while
the controller switched to raw framing, and every later connection on
the mapping was dead until it was re-added. The login now carries its
own `port_forward_multiplex`, filled with the target under the turn
lock: the probe asks, the raw pipe does not.

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

* port forward: a channel opened as its tunnel closes still gets the teardown

`open` can straddle `close_all`: the claim passed, the frame receiver was
still alive, and the channel subscribed after the signal had gone out.
`watch::subscribe` marks earlier sends as seen, and the entry sits in a
map that was already cleared, so nothing would ever end it. The signal is
now a level: `close_all` raises it with `send_replace`, which stores even
with no channel live, and `run_channel` waits for the value rather than
for a change.

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

* port forward: the connect guard counts a live tunnel as connected

`connect_port_forward_if_needed` returned early only for a raw-pipe
socket; called again with a tunnel up it would have built a second
`PortForwardMux` and dropped every channel of the first. Not reachable
today, since the logon response is sent once, but the other checks in
this change already read `is_port_forward()`.

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

* Urdu: the two terminal clipboard keys master added

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

* port forward: a tunnel's TCP stream refuses packets over twice MAX_FRAME

The codec takes a header declaring up to 1 GiB and hands the packet up
only once it has all arrived, so the channel window bounded what the
peer may send, not what this side buffers. Both sides now cap the codec
at 2 * MAX_FRAME as soon as multiplexing is agreed: a data frame with
its envelope and MAC fits with room to spare, and a header over the cap
ends the tunnel before a byte of payload is read. TCP only; the
WebSocket and WebRTC codecs carry caps of their own.

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

* port forward: a channel id still live when the counter comes round is skipped

The controller handed out `next_id` unchecked. 2^32 opens later it lands
on a channel still up: the entry here was replaced, while the peer,
which ignores an `open` for a live id, kept routing that id to the old
socket, so the new local connection's bytes went into the old target
connection. The id is now taken under the map's lock and advanced past
any id in use.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:59:51 +08:00

3203 lines
103 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/audio_input.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/printer_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/login.dart';
const double _kTabWidth = 200;
const double _kTabHeight = 42;
const double _kCardFixedWidth = 540;
const double _kCardLeftMargin = 15;
const double _kContentHMargin = 15;
const double _kContentHSubMargin = _kContentHMargin + 33;
const double _kCheckBoxLeftMargin = 10;
const double _kRadioLeftMargin = 10;
const double _kListViewBottomMargin = 15;
const double _kTitleFontSize = 20;
const double _kContentFontSize = 15;
const Color _accentColor = MyTheme.accent;
const String _kSettingPageControllerTag = 'settingPageController';
const String _kSettingPageTabKeyTag = 'settingPageTabKey';
class _TabInfo {
late final SettingsTabKey key;
late final String label;
late final IconData unselected;
late final IconData selected;
_TabInfo(this.key, this.label, this.unselected, this.selected);
}
enum SettingsTabKey {
general,
safety,
network,
display,
account,
printer,
about,
}
class DesktopSettingPage extends StatefulWidget {
final SettingsTabKey initialTabkey;
static final List<SettingsTabKey> tabKeys = [
if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
SettingsTabKey.general,
if (!isWeb &&
!bind.isOutgoingOnly() &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
SettingsTabKey.safety,
if (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
SettingsTabKey.network,
if (!bind.isIncomingOnly()) SettingsTabKey.display,
if (!bind.isDisableAccount()) SettingsTabKey.account,
if (isWindows &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
SettingsTabKey.printer,
SettingsTabKey.about,
];
DesktopSettingPage({Key? key, required this.initialTabkey}) : super(key: key);
@override
State<DesktopSettingPage> createState() =>
_DesktopSettingPageState(initialTabkey);
static void switch2page(SettingsTabKey page) {
try {
int index = tabKeys.indexOf(page);
if (index == -1) {
return;
}
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
DesktopTabPage.onAddSetting(initialPage: page);
PageController controller =
Get.find<PageController>(tag: _kSettingPageControllerTag);
Rx<SettingsTabKey> selected =
Get.find<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
selected.value = page;
controller.jumpToPage(index);
} else {
DesktopTabPage.onAddSetting(initialPage: page);
}
} catch (e) {
debugPrintStack(label: '$e');
}
}
}
class _DesktopSettingPageState extends State<DesktopSettingPage>
with
TickerProviderStateMixin,
AutomaticKeepAliveClientMixin,
WidgetsBindingObserver {
late PageController controller;
late Rx<SettingsTabKey> selectedTab;
@override
bool get wantKeepAlive => true;
final RxBool _block = false.obs;
final RxBool _canBeBlocked = false.obs;
Timer? _videoConnTimer;
_DesktopSettingPageState(SettingsTabKey initialTabkey) {
var initialIndex = DesktopSettingPage.tabKeys.indexOf(initialTabkey);
if (initialIndex == -1) {
initialIndex = 0;
}
selectedTab = DesktopSettingPage.tabKeys[initialIndex].obs;
Get.put<Rx<SettingsTabKey>>(selectedTab, tag: _kSettingPageTabKeyTag);
controller = PageController(initialPage: initialIndex);
Get.put<PageController>(controller, tag: _kSettingPageControllerTag);
controller.addListener(() {
if (controller.page != null) {
int page = controller.page!.toInt();
if (page < DesktopSettingPage.tabKeys.length) {
selectedTab.value = DesktopSettingPage.tabKeys[page];
}
}
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
shouldBeBlocked(_block, canBeBlocked);
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_videoConnTimer =
periodic_immediate(Duration(milliseconds: 1000), () async {
if (!mounted) {
return;
}
final blocked = await canBeBlocked();
if (!mounted) {
return;
}
_canBeBlocked.value = blocked;
});
}
@override
void dispose() {
_videoConnTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
Get.delete<PageController>(tag: _kSettingPageControllerTag);
Get.delete<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
// Get.delete does not dispose a plain ChangeNotifier.
controller.dispose();
super.dispose();
}
List<_TabInfo> _settingTabs() {
final List<_TabInfo> settingTabs = <_TabInfo>[];
for (final tab in DesktopSettingPage.tabKeys) {
switch (tab) {
case SettingsTabKey.general:
settingTabs.add(_TabInfo(
tab, 'General', Icons.settings_outlined, Icons.settings));
break;
case SettingsTabKey.safety:
settingTabs.add(_TabInfo(tab, 'Security',
Icons.enhanced_encryption_outlined, Icons.enhanced_encryption));
break;
case SettingsTabKey.network:
settingTabs
.add(_TabInfo(tab, 'Network', Icons.link_outlined, Icons.link));
break;
case SettingsTabKey.display:
settingTabs.add(_TabInfo(tab, 'Display',
Icons.desktop_windows_outlined, Icons.desktop_windows));
break;
case SettingsTabKey.account:
settingTabs.add(
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
break;
case SettingsTabKey.printer:
settingTabs
.add(_TabInfo(tab, 'Printer', Icons.print_outlined, Icons.print));
break;
case SettingsTabKey.about:
settingTabs
.add(_TabInfo(tab, 'About', Icons.info_outline, Icons.info));
break;
}
}
return settingTabs;
}
List<Widget> _children() {
final children = List<Widget>.empty(growable: true);
for (final tab in DesktopSettingPage.tabKeys) {
switch (tab) {
case SettingsTabKey.general:
children.add(const _General());
break;
case SettingsTabKey.safety:
children.add(const _Safety());
break;
case SettingsTabKey.network:
children.add(const _Network());
break;
case SettingsTabKey.display:
children.add(const _Display());
break;
case SettingsTabKey.account:
children.add(const _Account());
break;
case SettingsTabKey.printer:
children.add(const _Printer());
break;
case SettingsTabKey.about:
children.add(const _About());
break;
}
}
return children;
}
Widget _buildBlock({required List<Widget> children}) {
// check both mouseMoveTime and videoConnCount
return Obx(() {
final videoConnBlock =
_canBeBlocked.value && stateGlobal.videoConnCount > 0;
return Stack(children: [
buildRemoteBlock(
block: _block,
mask: false,
use: canBeBlocked,
child: preventMouseKeyBuilder(
child: Row(children: children),
block: videoConnBlock,
),
),
if (videoConnBlock)
Container(
color: Colors.black.withOpacity(0.5),
)
]);
});
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: _buildBlock(
children: <Widget>[
SizedBox(
width: _kTabWidth,
child: Column(
children: [
_header(context),
Flexible(child: _listView(tabs: _settingTabs())),
],
),
),
const VerticalDivider(width: 1),
Expanded(
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: PageView(
controller: controller,
physics: NeverScrollableScrollPhysics(),
children: _children(),
),
),
)
],
),
);
}
Widget _header(BuildContext context) {
final settingsText = Text(
translate('Settings'),
textAlign: TextAlign.left,
style: const TextStyle(
color: _accentColor,
fontSize: _kTitleFontSize,
fontWeight: FontWeight.w400,
),
);
return Row(
children: [
if (isWeb)
IconButton(
onPressed: () {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
icon: Icon(Icons.arrow_back),
).marginOnly(left: 5),
if (isWeb)
SizedBox(
height: 62,
child: Align(
alignment: Alignment.center,
child: settingsText,
),
).marginOnly(left: 20),
if (!isWeb)
SizedBox(
height: 62,
child: settingsText,
).marginOnly(left: 20, top: 10),
const Spacer(),
],
);
}
Widget _listView({required List<_TabInfo> tabs}) {
final scrollController = ScrollController();
return ListView(
controller: scrollController,
children: tabs.map((tab) => _listItem(tab: tab)).toList(),
);
}
Widget _listItem({required _TabInfo tab}) {
return Obx(() {
bool selected = tab.key == selectedTab.value;
return SizedBox(
width: _kTabWidth,
height: _kTabHeight,
child: InkWell(
onTap: () {
if (selectedTab.value != tab.key) {
int index = DesktopSettingPage.tabKeys.indexOf(tab.key);
if (index == -1) {
return;
}
controller.jumpToPage(index);
}
selectedTab.value = tab.key;
},
child: Row(children: [
Container(
width: 4,
height: _kTabHeight * 0.7,
color: selected ? _accentColor : null,
),
Icon(
selected ? tab.selected : tab.unselected,
color: selected ? _accentColor : null,
size: 20,
).marginOnly(left: 13, right: 10),
Text(
translate(tab.label),
style: TextStyle(
color: selected ? _accentColor : null,
fontWeight: FontWeight.w400,
fontSize: _kContentFontSize),
),
]),
),
);
});
}
}
//#region pages
class _General extends StatefulWidget {
const _General({Key? key}) : super(key: key);
@override
State<_General> createState() => _GeneralState();
}
class _GeneralState extends State<_General> {
final RxBool serviceStop =
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
RxBool serviceBtnEnabled = true.obs;
final GlobalKey _minToolbarOptionKey = GlobalKey();
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
return ListView(
controller: scrollController,
children: [
if (!isWeb) service(),
theme(),
_Card(title: 'Language', children: [language()]),
if (!isWeb) hwcodec(),
if (!isWeb) audio(context),
if (!isWeb) record(context),
if (!isWeb) WaylandCard(),
other()
],
).marginOnly(bottom: _kListViewBottomMargin);
}
Widget theme() {
final current = MyTheme.getThemeModePreference().toShortString();
onChanged(String value) async {
await MyTheme.changeDarkMode(MyTheme.themeModeFromString(value));
setState(() {});
}
final isOptFixed = isOptionFixed(kCommConfKeyTheme);
return _Card(title: 'Theme', children: [
_Radio<String>(context,
value: 'light',
groupValue: current,
label: 'Light',
onChanged: isOptFixed ? null : onChanged),
_Radio<String>(context,
value: 'dark',
groupValue: current,
label: 'Dark',
onChanged: isOptFixed ? null : onChanged),
_Radio<String>(context,
value: 'system',
groupValue: current,
label: 'Follow System',
onChanged: isOptFixed ? null : onChanged),
]);
}
Widget service() {
if (bind.isOutgoingOnly()) {
return const Offstage();
}
final hideStopService =
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
return Obx(() {
if (hideStopService && !serviceStop.value) {
return const Offstage();
}
return _Card(title: 'Service', children: [
_Button(serviceStop.value ? 'Start' : 'Stop', () {
() async {
serviceBtnEnabled.value = false;
await start_service(serviceStop.value);
// enable the button after 1 second
Future.delayed(const Duration(seconds: 1), () {
serviceBtnEnabled.value = true;
});
}();
}, enabled: serviceBtnEnabled.value)
]);
});
}
Widget other() {
final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
final children = <Widget>[
if (!isWeb && !incomingOnly)
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs,
isServer: false),
if (!incomingOnly)
_OptionCheckBox(
context,
'allow-remote-toolbar-docking-any-edge',
kOptionAllowMultiEdgeToolbarDock,
isServer: false,
update: (_) {
reloadAllWindows();
},
),
if (!isWeb && !outgoingOnly)
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb) wallpaper(),
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Open connection in new tab',
kOptionOpenNewConnInTabs,
isServer: false,
),
Tooltip(
message: translate('port-forward-mux-tip'),
child: _OptionCheckBox(
context,
'Reuse one connection for port forwarding',
kOptionEnablePortForwardMux,
isServer: false,
),
),
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local
if (isLinux)
Tooltip(
message: translate('software_render_tip'),
child: _OptionCheckBox(
context,
"Always use software rendering",
kOptionAllowAlwaysSoftwareRender,
),
),
if (!isWeb)
Tooltip(
message: translate('texture_render_tip'),
child: _OptionCheckBox(
context,
"Use texture rendering",
kOptionTextureRender,
optGetter: bind.mainGetUseTextureRender,
optSetter: (k, v) async =>
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
),
),
if (isWindows)
Tooltip(
message: translate('d3d_render_tip'),
child: _OptionCheckBox(
context,
"Use D3D rendering",
kOptionD3DRender,
isServer: false,
),
),
],
if (!isWeb && !bind.isCustomClient())
_OptionCheckBox(
context,
'Check for software update on startup',
kOptionEnableCheckUpdate,
isServer: false,
),
if (showAutoUpdate)
_OptionCheckBox(
context,
'Auto update',
kOptionAllowAutoUpdate,
isServer: true,
),
if (isWindows && !outgoingOnly)
_OptionCheckBox(
context,
'Capture screen using DirectX',
kOptionDirectxCapture,
),
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Enable UDP hole punching',
kOptionEnableUdpPunch,
isServer: false,
),
_OptionCheckBox(
context,
'Enable IPv6 P2P connection',
kOptionEnableIpv6Punch,
isServer: false,
),
Tooltip(
message: translate('sync-clipboard-between-sessions-tip'),
child: _OptionCheckBox(
context,
'Sync clipboard between sessions',
kOptionAllowSyncClipboardBetweenSessions,
isServer: false,
),
),
],
];
// Add client-side wakelock option for desktop platforms
if (!bind.isIncomingOnly()) {
children.add(_OptionCheckBox(
context,
'keep-awake-during-outgoing-sessions-label',
kOptionKeepAwakeDuringOutgoingSessions,
isServer: false,
));
}
if (!bind.isDisableAccount()) {
children.add(_OptionCheckBox(
context,
'note-at-conn-end-tip',
kOptionAllowAskForNoteAtEndOfConnection,
isServer: false,
optSetter: (key, value) async {
if (value && !gFFI.userModel.isLogin) {
final res = await loginDialog();
if (res != true) return;
}
await mainSetLocalBoolOption(key, value);
},
));
}
children.add(_OptionCheckBox(
context,
'Show monitor switch button on the main toolbar',
kOptionAllowMonitorSwitchMainToolbar,
isServer: false,
update: (enabled) async {
if (!enabled) {
await mainSetLocalBoolOption(
kOptionAllowMonitorSwitchMinToolbar, false);
}
if (mounted) setState(() {});
reloadAllWindows();
if (enabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _minToolbarOptionKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
}
},
));
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
children.add(KeyedSubtree(
key: _minToolbarOptionKey,
child: _OptionCheckBox(
context,
'Show on the minimized toolbar',
kOptionAllowMonitorSwitchMinToolbar,
isServer: false,
update: (_) {
reloadAllWindows();
},
).marginOnly(left: _kCheckBoxLeftMargin * 3),
));
}
return _Card(title: 'Other', children: children);
}
Widget wallpaper() {
if (bind.isOutgoingOnly()) {
return const Offstage();
}
return futureBuilder(future: () async {
final support = await bind.mainSupportRemoveWallpaper();
return support;
}(), hasData: (data) {
if (data is bool && data == true) {
bool value = mainGetBoolOptionSync(kOptionAllowRemoveWallpaper);
return Row(
children: [
Flexible(
child: _OptionCheckBox(
context,
'Remove wallpaper during incoming sessions',
kOptionAllowRemoveWallpaper,
update: (bool v) {
setState(() {});
},
),
),
if (value)
_CountDownButton(
text: 'Test',
second: 5,
onPressed: () {
bind.mainTestWallpaper(second: 5);
},
)
],
);
}
return Offstage();
});
}
Widget hwcodec() {
final hwcodec = bind.mainHasHwcodec();
final vram = bind.mainHasVram();
return Offstage(
offstage: !(hwcodec || vram),
child: _Card(title: 'Hardware Codec', children: [
_OptionCheckBox(
context,
'Enable hardware codec',
kOptionEnableHwcodec,
update: (bool v) {
if (v) {
bind.mainCheckHwcodec();
}
},
)
]),
);
}
Widget audio(BuildContext context) {
if (bind.isOutgoingOnly()) {
return const Offstage();
}
builder(devices, currentDevice, setDevice) {
final child = ComboBox(
keys: devices,
values: devices,
initialKey: currentDevice,
onChanged: (key) async {
setDevice(key);
setState(() {});
},
).marginOnly(left: _kContentHMargin);
return _Card(title: 'Audio Input Device', children: [child]);
}
return AudioInput(builder: builder, isCm: false, isVoiceCall: false);
}
Widget record(BuildContext context) {
final showRootDir = isWindows && bind.mainIsInstalled();
return futureBuilder(future: () async {
String user_dir = bind.mainVideoSaveDirectory(root: false);
String root_dir =
showRootDir ? bind.mainVideoSaveDirectory(root: true) : '';
bool user_dir_exists = await Directory(user_dir).exists();
bool root_dir_exists =
showRootDir ? await Directory(root_dir).exists() : false;
return {
'user_dir': user_dir,
'root_dir': root_dir,
'user_dir_exists': user_dir_exists,
'root_dir_exists': root_dir_exists,
};
}(), hasData: (data) {
Map<String, dynamic> map = data as Map<String, dynamic>;
String user_dir = map['user_dir']!;
String root_dir = map['root_dir']!;
bool root_dir_exists = map['root_dir_exists']!;
bool user_dir_exists = map['user_dir_exists']!;
return _Card(title: 'Recording', children: [
if (!bind.isOutgoingOnly())
_OptionCheckBox(context, 'Automatically record incoming sessions',
kOptionAllowAutoRecordIncoming),
if (!bind.isIncomingOnly())
_OptionCheckBox(context, 'Automatically record outgoing sessions',
kOptionAllowAutoRecordOutgoing,
isServer: false),
if (showRootDir && !bind.isOutgoingOnly())
Row(
children: [
Text(
'${translate(bind.isIncomingOnly() ? "Directory" : "Incoming")}:'),
Expanded(
child: GestureDetector(
onTap: root_dir_exists
? () => launchUrl(Uri.file(root_dir))
: null,
child: Text(
root_dir,
softWrap: true,
style: root_dir_exists
? const TextStyle(
decoration: TextDecoration.underline)
: null,
)).marginOnly(left: 10),
),
],
).marginOnly(left: _kContentHMargin),
if (!(showRootDir && bind.isIncomingOnly()))
Row(
children: [
Text(
'${translate((showRootDir && !bind.isOutgoingOnly()) ? "Outgoing" : "Directory")}:'),
Expanded(
child: GestureDetector(
onTap: user_dir_exists
? () => launchUrl(Uri.file(user_dir))
: null,
child: Text(
user_dir,
softWrap: true,
style: user_dir_exists
? const TextStyle(
decoration: TextDecoration.underline)
: null,
)).marginOnly(left: 10),
),
ElevatedButton(
onPressed: isOptionFixed(kOptionVideoSaveDirectory)
? null
: () async {
String? initialDirectory;
if (await Directory.fromUri(
Uri.directory(user_dir))
.exists()) {
initialDirectory = user_dir;
}
String? selectedDirectory =
await FilePicker.platform.getDirectoryPath(
initialDirectory: initialDirectory);
if (selectedDirectory != null) {
await bind.mainSetLocalOption(
key: kOptionVideoSaveDirectory,
value: selectedDirectory);
setState(() {});
}
},
child: Text(translate('Change')))
.marginOnly(left: 5),
],
).marginOnly(left: _kContentHMargin),
]);
});
}
Widget language() {
return futureBuilder(future: () async {
String langs = await bind.mainGetLangs();
return {'langs': langs};
}(), hasData: (res) {
Map<String, String> data = res as Map<String, String>;
List<dynamic> langsList = jsonDecode(data['langs']!);
Map<String, String> langsMap = {for (var v in langsList) v[0]: v[1]};
List<String> keys = langsMap.keys.toList();
List<String> values = langsMap.values.toList();
keys.insert(0, defaultOptionLang);
values.insert(0, translate('Default'));
String currentKey = bind.mainGetLocalOption(key: kCommConfKeyLang);
if (!keys.contains(currentKey)) {
currentKey = defaultOptionLang;
}
final isOptFixed = isOptionFixed(kCommConfKeyLang);
return ComboBox(
keys: keys,
values: values,
initialKey: currentKey,
onChanged: (key) async {
await bind.mainSetLocalOption(key: kCommConfKeyLang, value: key);
if (isWeb) reloadCurrentWindow();
if (!isWeb) reloadAllWindows();
if (!isWeb) bind.mainChangeLanguage(lang: key);
},
enabled: !isOptFixed,
).marginOnly(left: _kContentHMargin);
});
}
}
enum _AccessMode {
custom,
full,
view,
}
class _Safety extends StatefulWidget {
const _Safety({Key? key}) : super(key: key);
@override
State<_Safety> createState() => _SafetyState();
}
class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
bool locked = bind.mainIsInstalled();
final scrollController = ScrollController();
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
controller: scrollController,
child: Column(
children: [
_lock(locked, 'Unlock Security Settings', () {
locked = false;
setState(() => {});
}),
preventMouseKeyBuilder(
block: locked,
child: Column(children: [
permissions(context),
password(context),
_Card(title: '2FA', children: [tfa()]),
if (!isChangeIdDisabled())
_Card(title: 'ID', children: [changeId()]),
more(context),
]),
),
],
)).marginOnly(bottom: _kListViewBottomMargin);
}
Widget tfa() {
bool enabled = !locked;
// Simple temp wrapper for PR check
tmpWrapper() {
RxBool has2fa = bind.mainHasValid2FaSync().obs;
RxBool hasBot = bind.mainHasValidBotSync().obs;
update() async {
has2fa.value = bind.mainHasValid2FaSync();
setState(() {});
}
onChanged(bool? checked) async {
if (checked == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-2fa-confirm-tip'), () {
change2fa(callback: update);
});
} else {
change2fa(callback: update);
}
}
final tfa = GestureDetector(
child: InkWell(
child: Obx(() => Row(
children: [
Checkbox(
value: has2fa.value,
onChanged: enabled ? onChanged : null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('enable-2fa-title'),
style:
TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
),
onTap: () {
onChanged(!has2fa.value);
},
).marginOnly(left: _kCheckBoxLeftMargin);
if (!has2fa.value) {
return tfa;
}
updateBot() async {
hasBot.value = bind.mainHasValidBotSync();
setState(() {});
}
onChangedBot(bool? checked) async {
if (checked == false) {
CommonConfirmDialog(
gFFI.dialogManager, translate('cancel-bot-confirm-tip'), () {
changeBot(callback: updateBot);
});
} else {
changeBot(callback: updateBot);
}
}
final bot = GestureDetector(
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate("enable-bot-tip"),
child: InkWell(
child: Obx(() => Row(
children: [
Checkbox(
value: hasBot.value,
onChanged: enabled ? onChangedBot : null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Telegram bot'),
style: TextStyle(
color: disabledTextColor(context, enabled)),
))
],
))),
),
onTap: () {
onChangedBot(!hasBot.value);
},
).marginOnly(left: _kCheckBoxLeftMargin + 30);
final trust = Row(
children: [
Flexible(
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate("enable-trusted-devices-tip"),
child: _OptionCheckBox(context, "Enable trusted devices",
kOptionEnableTrustedDevices,
enabled: !locked, update: (v) {
setState(() {});
}),
),
),
if (mainGetBoolOptionSync(kOptionEnableTrustedDevices))
ElevatedButton(
onPressed: locked
? null
: () {
manageTrustedDeviceDialog();
},
child: Text(translate('Manage trusted devices')))
],
).marginOnly(left: 30);
return Column(
children: [tfa, bot, trust],
);
}
return tmpWrapper();
}
Widget changeId() {
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
child: Consumer<ServerModel>(builder: ((context, model, child) {
return _Button('Change ID', changeIdDialog,
enabled: !locked && model.connectStatus > 0);
})));
}
Widget permissions(context) {
bool enabled = !locked;
// Simple temp wrapper for PR check
tmpWrapper() {
String accessMode = bind.mainGetOptionSync(key: kOptionAccessMode);
_AccessMode mode;
if (accessMode == 'full') {
mode = _AccessMode.full;
} else if (accessMode == 'view') {
mode = _AccessMode.view;
} else {
mode = _AccessMode.custom;
}
String initialKey;
bool? fakeValue;
switch (mode) {
case _AccessMode.custom:
initialKey = '';
fakeValue = null;
break;
case _AccessMode.full:
initialKey = 'full';
fakeValue = true;
break;
case _AccessMode.view:
initialKey = 'view';
fakeValue = false;
break;
}
return _Card(title: 'Permissions', children: [
ComboBox(
keys: [
defaultOptionAccessMode,
'full',
'view',
],
values: [
translate('Custom'),
translate('Full Access'),
translate('Screen Share'),
],
enabled: enabled && !isOptionFixed(kOptionAccessMode),
initialKey: initialKey,
onChanged: (mode) async {
await bind.mainSetOption(key: kOptionAccessMode, value: mode);
setState(() {});
}).marginOnly(left: _kContentHMargin),
Column(
children: [
_OptionCheckBox(
context, 'Enable keyboard/mouse', kOptionEnableKeyboard,
enabled: enabled, fakeValue: fakeValue),
if (isWindows)
_OptionCheckBox(
context, 'Enable remote printer', kOptionEnableRemotePrinter,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable clipboard', kOptionEnableClipboard,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable file transfer', kOptionEnableFileTransfer,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable audio', kOptionEnableAudio,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable terminal', kOptionEnableTerminal,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable TCP tunneling', kOptionEnableTunnel,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable remote restart', kOptionEnableRemoteRestart,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable recording session', kOptionEnableRecordSession,
enabled: enabled, fakeValue: fakeValue),
if (isWindows)
_OptionCheckBox(context, 'Enable blocking user input',
kOptionEnableBlockInput,
enabled: enabled, fakeValue: fakeValue),
if (bind.mainSupportedPrivacyModeImpls() != '[]')
_OptionCheckBox(
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable remote configuration modification',
kOptionAllowRemoteConfigModification,
enabled: enabled, fakeValue: fakeValue),
],
),
]);
}
return tmpWrapper();
}
Widget password(BuildContext context) {
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
child: Consumer<ServerModel>(builder: ((context, model, child) {
List<String> passwordKeys = [
kUseTemporaryPassword,
kUsePermanentPassword,
kUseBothPasswords,
];
List<String> passwordValues = [
translate('Use one-time password'),
translate('Use permanent password'),
translate('Use both passwords'),
];
bool tmpEnabled = model.verificationMethod != kUsePermanentPassword;
bool permEnabled = model.verificationMethod != kUseTemporaryPassword;
String currentValue =
passwordValues[passwordKeys.indexOf(model.verificationMethod)];
List<Widget> radios = passwordValues
.map((value) => _Radio<String>(
context,
value: value,
groupValue: currentValue,
label: value,
onChanged: locked
? null
: ((value) async {
callback() async {
await model.setVerificationMethod(
passwordKeys[passwordValues.indexOf(value)]);
await model.updatePasswordModel();
}
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetCommon(
key: "permanent-password-set")) !=
"true") {
if (isChangePermanentPasswordDisabled()) {
await callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
await callback();
}
}),
))
.toList();
var onChanged = tmpEnabled && !locked
? (value) {
if (value != null) {
() async {
await model.setTemporaryPasswordLength(value.toString());
await model.updatePasswordModel();
}();
}
}
: null;
List<Widget> lengthRadios = ['6', '8', '10']
.map((value) => GestureDetector(
child: Row(
children: [
Radio(
value: value,
groupValue: model.temporaryPasswordLength,
onChanged: onChanged),
Text(
value,
style: TextStyle(
color: disabledTextColor(
context, onChanged != null)),
),
],
).paddingOnly(right: 10),
onTap: () => onChanged?.call(value),
))
.toList();
final isOptFixedNumOTP =
isOptionFixed(kOptionAllowNumericOneTimePassword);
final isNumOPTChangable = !isOptFixedNumOTP && tmpEnabled && !locked;
final numericOneTimePassword = GestureDetector(
child: InkWell(
child: Row(
children: [
Checkbox(
value: model.allowNumericOneTimePassword,
onChanged: isNumOPTChangable
? (bool? v) {
model.switchAllowNumericOneTimePassword();
}
: null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Numeric one-time password'),
style: TextStyle(
color: disabledTextColor(context, isNumOPTChangable)),
))
],
)),
onTap: isNumOPTChangable
? () => model.switchAllowNumericOneTimePassword()
: null,
).marginOnly(left: _kContentHSubMargin - 5);
final modeKeys = <String>[
'password',
'click',
defaultOptionApproveMode
];
final modeValues = [
translate('Accept sessions via password'),
translate('Accept sessions via click'),
translate('Accept sessions via both'),
];
var modeInitialKey = model.approveMode;
if (!modeKeys.contains(modeInitialKey)) {
modeInitialKey = defaultOptionApproveMode;
}
final usePassword = model.approveMode != 'click';
final isApproveModeFixed = isOptionFixed(kOptionApproveMode);
return _Card(title: 'Password', children: [
ComboBox(
enabled: !locked && !isApproveModeFixed,
keys: modeKeys,
values: modeValues,
initialKey: modeInitialKey,
onChanged: (key) => model.setApproveMode(key),
).marginOnly(left: _kContentHMargin),
if (usePassword) radios[0],
if (usePassword)
_SubLabeledWidget(
context,
'One-time password length',
Row(
children: [
...lengthRadios,
],
),
enabled: tmpEnabled && !locked),
if (usePassword) numericOneTimePassword,
if (usePassword) radios[1],
if (usePassword && !isChangePermanentPasswordDisabled())
_SubButton('Set permanent password', setPasswordDialog,
permEnabled && !locked),
// if (usePassword)
// hide_cm(!locked).marginOnly(left: _kContentHSubMargin - 6),
if (usePassword) radios[2],
]);
})));
}
Widget more(BuildContext context) {
bool enabled = !locked;
return _Card(title: 'Security', children: [
shareRdp(context, enabled),
_OptionCheckBox(context, 'Deny LAN discovery', 'enable-lan-discovery',
reverse: true, enabled: enabled),
...directIp(context),
whitelist(),
idWhitelist(),
...autoDisconnect(context),
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
kOptionKeepAwakeDuringIncomingSessions,
reverse: false, enabled: enabled),
if (bind.mainIsInstalled())
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
'allow-only-conn-window-open',
reverse: false, enabled: enabled),
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
]);
}
shareRdp(BuildContext context, bool enabled) {
onChanged(bool b) async {
await bind.mainSetShareRdp(enable: b);
setState(() {});
}
bool value = bind.mainIsShareRdp();
return Offstage(
offstage: !(isWindows && bind.mainIsInstalled()),
child: GestureDetector(
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (_) => onChanged(!value) : null)
.marginOnly(right: 5),
Expanded(
child: Text(translate('Enable RDP session sharing'),
style:
TextStyle(color: disabledTextColor(context, enabled))),
)
],
).marginOnly(left: _kCheckBoxLeftMargin),
onTap: enabled ? () => onChanged(!value) : null),
);
}
List<Widget> directIp(BuildContext context) {
TextEditingController controller = TextEditingController();
update(bool v) => setState(() {});
RxBool applyEnabled = false.obs;
return [
_OptionCheckBox(context, 'Enable direct IP access', kOptionDirectServer,
update: update, enabled: !locked),
() {
// Simple temp wrapper for PR check
tmpWrapper() {
bool enabled = option2bool(kOptionDirectServer,
bind.mainGetOptionSync(key: kOptionDirectServer));
if (!enabled) applyEnabled.value = false;
controller.text =
bind.mainGetOptionSync(key: kOptionDirectAccessPort);
final isOptFixed = isOptionFixed(kOptionDirectAccessPort);
return Offstage(
offstage: !enabled,
child: _SubLabeledWidget(
context,
'Port',
Row(children: [
SizedBox(
width: 95,
child: TextField(
controller: controller,
enabled: enabled && !locked && !isOptFixed,
onChanged: (_) => applyEnabled.value = true,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(
r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')),
],
decoration: const InputDecoration(
hintText: '21118',
contentPadding:
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
).workaroundFreezeLinuxMint().marginOnly(right: 15),
),
Obx(() => ElevatedButton(
onPressed: applyEnabled.value &&
enabled &&
!locked &&
!isOptFixed
? () async {
applyEnabled.value = false;
await bind.mainSetOption(
key: kOptionDirectAccessPort,
value: controller.text);
}
: null,
child: Text(
translate('Apply'),
),
))
]),
enabled: enabled && !locked && !isOptFixed,
),
);
}
return tmpWrapper();
}(),
];
}
Widget whitelist() {
bool enabled = !locked;
// Simple temp wrapper for PR check
tmpWrapper() {
RxBool hasWhitelist = whitelistNotEmpty().obs;
update() async {
hasWhitelist.value = whitelistNotEmpty();
}
onChanged(bool? checked) async {
changeWhiteList(callback: update);
}
final isOptFixed = isOptionFixed(kOptionWhitelist);
return GestureDetector(
child: Tooltip(
message: translate('whitelist_tip'),
child: Obx(() => Row(
children: [
Checkbox(
value: hasWhitelist.value,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Offstage(
offstage: !hasWhitelist.value,
child: MouseRegion(
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0))
.marginOnly(right: 5),
cursor: SystemMouseCursors.click,
),
),
Expanded(
child: Text(
translate('Use IP Whitelisting'),
style:
TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
),
onTap: enabled
? () {
onChanged(!hasWhitelist.value);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
return tmpWrapper();
}
Widget idWhitelist() {
bool enabled = !locked;
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
update() async {
hasIdWhitelist.value = idWhitelistNotEmpty();
}
onChanged(bool? checked) async {
changeIdWhiteList(callback: update);
}
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
return GestureDetector(
child: Tooltip(
message: translate('id_whitelist_tip'),
child: Obx(() => Row(
children: [
Checkbox(
value: hasIdWhitelist.value,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Offstage(
offstage: !hasIdWhitelist.value,
child: MouseRegion(
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0))
.marginOnly(right: 5),
cursor: SystemMouseCursors.click,
),
),
Expanded(
child: Text(
translate('Use ID whitelisting'),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
),
onTap: enabled
? () {
onChanged(!hasIdWhitelist.value);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
Widget hide_cm(bool enabled) {
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
child: Consumer<ServerModel>(builder: (context, model, child) {
final enableHideCm = model.approveMode == 'password' &&
model.verificationMethod == kUsePermanentPassword;
onHideCmChanged(bool? b) {
if (b != null) {
bind.mainSetOption(
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', b));
}
}
return Tooltip(
message: enableHideCm ? "" : translate('hide_cm_tip'),
child: GestureDetector(
onTap:
enableHideCm ? () => onHideCmChanged(!model.hideCm) : null,
child: Row(
children: [
Checkbox(
value: model.hideCm,
onChanged: enabled && enableHideCm
? onHideCmChanged
: null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Hide connection management window'),
style: TextStyle(
color: disabledTextColor(
context, enabled && enableHideCm)),
),
),
],
),
));
}));
}
List<Widget> autoDisconnect(BuildContext context) {
TextEditingController controller = TextEditingController();
update(bool v) => setState(() {});
RxBool applyEnabled = false.obs;
return [
_OptionCheckBox(
context, 'auto_disconnect_option_tip', kOptionAllowAutoDisconnect,
update: update, enabled: !locked),
() {
bool enabled = option2bool(kOptionAllowAutoDisconnect,
bind.mainGetOptionSync(key: kOptionAllowAutoDisconnect));
if (!enabled) applyEnabled.value = false;
controller.text =
bind.mainGetOptionSync(key: kOptionAutoDisconnectTimeout);
final isOptFixed = isOptionFixed(kOptionAutoDisconnectTimeout);
return Offstage(
offstage: !enabled,
child: _SubLabeledWidget(
context,
'Timeout in minutes',
Row(children: [
SizedBox(
width: 95,
child: TextField(
controller: controller,
enabled: enabled && !locked && !isOptFixed,
onChanged: (_) => applyEnabled.value = true,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(
r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')),
],
decoration: const InputDecoration(
hintText: '10',
contentPadding:
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
).workaroundFreezeLinuxMint().marginOnly(right: 15),
),
Obx(() => ElevatedButton(
onPressed:
applyEnabled.value && enabled && !locked && !isOptFixed
? () async {
applyEnabled.value = false;
await bind.mainSetOption(
key: kOptionAutoDisconnectTimeout,
value: controller.text);
}
: null,
child: Text(
translate('Apply'),
),
))
]),
enabled: enabled && !locked && !isOptFixed,
),
);
}(),
];
}
Widget unlockPin() {
bool enabled = !locked;
RxString unlockPin = bind.mainGetUnlockPin().obs;
update() async {
unlockPin.value = bind.mainGetUnlockPin();
}
onChanged(bool? checked) async {
changeUnlockPinDialog(unlockPin.value, update);
}
final isOptFixed = isOptionFixed(kOptionWhitelist);
return GestureDetector(
child: Obx(() => Row(
children: [
Checkbox(
value: unlockPin.isNotEmpty,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Expanded(
child: Text(
translate('Unlock with PIN'),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
onTap: enabled
? () {
onChanged(!unlockPin.isNotEmpty);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
}
class _Network extends StatefulWidget {
const _Network({Key? key}) : super(key: key);
@override
State<_Network> createState() => _NetworkState();
}
class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
bool locked = !isWeb && bind.mainIsInstalled();
final scrollController = ScrollController();
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(controller: scrollController, children: [
_lock(locked, 'Unlock Network Settings', () {
locked = false;
setState(() => {});
}),
preventMouseKeyBuilder(
block: locked,
child: Column(children: [
network(context),
]),
),
]).marginOnly(bottom: _kListViewBottomMargin);
}
Widget network(BuildContext context) {
final hideServer =
bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y';
final hideProxy =
isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
final hideWebSocket = isWeb ||
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y';
if (hideServer && hideProxy && hideWebSocket) {
return Offstage();
}
// Helper function to create network setting ListTiles
Widget listTile({
required IconData icon,
required String title,
VoidCallback? onTap,
Widget? trailing,
bool showTooltip = false,
String tooltipMessage = '',
}) {
final titleWidget = showTooltip
? Row(
children: [
Tooltip(
waitDuration: Duration(milliseconds: 1000),
message: translate(tooltipMessage),
child: Row(
children: [
Text(
translate(title),
style: TextStyle(fontSize: _kContentFontSize),
),
SizedBox(width: 5),
Icon(
Icons.help_outline,
size: 14,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.7),
),
],
),
),
],
)
: Text(
translate(title),
style: TextStyle(fontSize: _kContentFontSize),
);
return ListTile(
leading: Icon(icon, color: _accentColor),
title: titleWidget,
enabled: !locked,
onTap: onTap,
trailing: trailing,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16),
minLeadingWidth: 0,
horizontalTitleGap: 10,
);
}
Widget switchWidget(IconData icon, String title, String tooltipMessage,
String optionKey) =>
listTile(
icon: icon,
title: title,
showTooltip: true,
tooltipMessage: tooltipMessage,
trailing: Switch(
value: mainGetBoolOptionSync(optionKey),
onChanged: locked || isOptionFixed(optionKey)
? null
: (value) {
mainSetBoolOption(optionKey, value);
setState(() {});
},
),
);
final outgoingOnly = bind.isOutgoingOnly();
final divider = const Divider(height: 1, indent: 16, endIndent: 16);
return _Card(
title: 'Network',
children: [
Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!hideServer)
listTile(
icon: Icons.dns_outlined,
title: 'ID/Relay Server',
onTap: () => showServerSettings(gFFI.dialogManager, setState),
),
if (!hideProxy && !hideServer) divider,
if (!hideProxy)
listTile(
icon: Icons.network_ping_outlined,
title: 'Socks5/Http(s) Proxy',
onTap: changeSocks5Proxy,
),
if (!hideWebSocket && (!hideServer || !hideProxy)) divider,
if (!hideWebSocket)
switchWidget(
Icons.web_asset_outlined,
'Use WebSocket',
'${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}',
kOptionAllowWebSocket),
if (!isWeb)
futureBuilder(
future: bind.mainIsUsingPublicServer(),
hasData: (isUsingPublicServer) {
if (isUsingPublicServer) {
return Offstage();
} else {
return Column(
children: [
if (!hideServer || !hideProxy || !hideWebSocket)
divider,
switchWidget(
Icons.no_encryption_outlined,
'Allow insecure TLS fallback',
'allow-insecure-tls-fallback-tip',
kOptionAllowInsecureTLSFallback),
if (!outgoingOnly) divider,
if (!outgoingOnly)
listTile(
icon: Icons.lan_outlined,
title: 'Disable UDP',
showTooltip: true,
tooltipMessage:
'${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}',
trailing: Switch(
value: bind.mainGetOptionSync(
key: kOptionDisableUdp) ==
'Y',
onChanged:
locked || isOptionFixed(kOptionDisableUdp)
? null
: (value) async {
await bind.mainSetOption(
key: kOptionDisableUdp,
value: value ? 'Y' : 'N');
setState(() {});
},
),
),
],
);
}
},
),
],
),
),
],
);
}
}
class _Display extends StatefulWidget {
const _Display({Key? key}) : super(key: key);
@override
State<_Display> createState() => _DisplayState();
}
class _DisplayState extends State<_Display> {
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
return ListView(controller: scrollController, children: [
viewStyle(context),
scrollStyle(context),
imageQuality(context),
codec(context),
if (isDesktop) trackpadSpeed(context),
if (!isWeb) privacyModeImpl(context),
other(context),
]).marginOnly(bottom: _kListViewBottomMargin);
}
Widget viewStyle(BuildContext context) {
final isOptFixed = isOptionFixed(kOptionViewStyle);
onChanged(String value) async {
await bind.mainSetUserDefaultOption(key: kOptionViewStyle, value: value);
setState(() {});
}
final groupValue = bind.mainGetUserDefaultOption(key: kOptionViewStyle);
return _Card(title: 'Default View Style', children: [
_Radio(context,
value: kRemoteViewStyleOriginal,
groupValue: groupValue,
label: 'Scale original',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: kRemoteViewStyleAdaptive,
groupValue: groupValue,
label: 'Scale adaptive',
onChanged: isOptFixed ? null : onChanged),
]);
}
Widget scrollStyle(BuildContext context) {
final isOptFixed = isOptionFixed(kOptionScrollStyle);
onChanged(String value) async {
await bind.mainSetUserDefaultOption(
key: kOptionScrollStyle, value: value);
setState(() {});
}
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
onEdgeScrollEdgeThicknessChanged(double value) async {
await bind.mainSetUserDefaultOption(
key: kOptionEdgeScrollEdgeThickness, value: value.round().toString());
setState(() {});
}
return _Card(title: 'Default Scroll Style', children: [
_Radio(context,
value: kRemoteScrollStyleAuto,
groupValue: groupValue,
label: 'ScrollAuto',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: kRemoteScrollStyleBar,
groupValue: groupValue,
label: 'Scrollbar',
onChanged: isOptFixed ? null : onChanged),
if (!isWeb) ...[
_Radio(context,
value: kRemoteScrollStyleEdge,
groupValue: groupValue,
label: 'ScrollEdge',
onChanged: isOptFixed ? null : onChanged),
Offstage(
offstage: groupValue != kRemoteScrollStyleEdge,
child: EdgeThicknessControl(
value: double.tryParse(bind.mainGetUserDefaultOption(
key: kOptionEdgeScrollEdgeThickness)) ??
100.0,
onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness)
? null
: onEdgeScrollEdgeThicknessChanged,
)),
],
]);
}
Widget imageQuality(BuildContext context) {
onChanged(String value) async {
await bind.mainSetUserDefaultOption(
key: kOptionImageQuality, value: value);
setState(() {});
}
final isOptFixed = isOptionFixed(kOptionImageQuality);
final groupValue = bind.mainGetUserDefaultOption(key: kOptionImageQuality);
return _Card(title: 'Default Image Quality', children: [
_Radio(context,
value: kRemoteImageQualityBest,
groupValue: groupValue,
label: 'Good image quality',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: kRemoteImageQualityBalanced,
groupValue: groupValue,
label: 'Balanced',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: kRemoteImageQualityLow,
groupValue: groupValue,
label: 'Optimize reaction time',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: kRemoteImageQualityCustom,
groupValue: groupValue,
label: 'Custom',
onChanged: isOptFixed ? null : onChanged),
Offstage(
offstage: groupValue != kRemoteImageQualityCustom,
child: customImageQualitySetting(),
)
]);
}
Widget trackpadSpeed(BuildContext context) {
final initSpeed =
(int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
kDefaultTrackpadSpeed);
final curSpeed = SimpleWrapper(initSpeed);
void onDebouncer(int v) {
bind.mainSetUserDefaultOption(
key: kKeyTrackpadSpeed, value: v.toString());
// It's better to notify all sessions that the default speed is changed.
// But it may also be ok to take effect in the next connection.
}
return _Card(title: 'Default trackpad speed', children: [
TrackpadSpeedWidget(
value: curSpeed,
onDebouncer: onDebouncer,
),
]);
}
Widget codec(BuildContext context) {
onChanged(String value) async {
await bind.mainSetUserDefaultOption(
key: kOptionCodecPreference, value: value);
setState(() {});
}
final groupValue =
bind.mainGetUserDefaultOption(key: kOptionCodecPreference);
var hwRadios = [];
final isOptFixed = isOptionFixed(kOptionCodecPreference);
try {
final Map codecsJson = jsonDecode(bind.mainSupportedHwdecodings());
final h264 = codecsJson['h264'] ?? false;
final h265 = codecsJson['h265'] ?? false;
if (h264) {
hwRadios.add(_Radio(context,
value: 'h264',
groupValue: groupValue,
label: 'H264',
onChanged: isOptFixed ? null : onChanged));
}
if (h265) {
hwRadios.add(_Radio(context,
value: 'h265',
groupValue: groupValue,
label: 'H265',
onChanged: isOptFixed ? null : onChanged));
}
} catch (e) {
debugPrint("failed to parse supported hwdecodings, err=$e");
}
return _Card(title: 'Default Codec', children: [
_Radio(context,
value: 'auto',
groupValue: groupValue,
label: 'Auto',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: 'vp8',
groupValue: groupValue,
label: 'VP8',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: 'vp9',
groupValue: groupValue,
label: 'VP9',
onChanged: isOptFixed ? null : onChanged),
_Radio(context,
value: 'av1',
groupValue: groupValue,
label: 'AV1',
onChanged: isOptFixed ? null : onChanged),
...hwRadios,
]);
}
Widget privacyModeImpl(BuildContext context) {
final supportedPrivacyModeImpls = bind.mainSupportedPrivacyModeImpls();
late final List<dynamic> privacyModeImpls;
try {
privacyModeImpls = jsonDecode(supportedPrivacyModeImpls);
} catch (e) {
debugPrint('failed to parse supported privacy mode impls, err=$e');
return Offstage();
}
if (privacyModeImpls.length < 2) {
return Offstage();
}
final key = 'privacy-mode-impl-key';
onChanged(String value) async {
await bind.mainSetOption(key: key, value: value);
setState(() {});
}
String groupValue = bind.mainGetOptionSync(key: key);
if (groupValue.isEmpty) {
groupValue = bind.mainDefaultPrivacyModeImpl();
}
return _Card(
title: 'Privacy mode',
children: privacyModeImpls.map((impl) {
final d = impl as List<dynamic>;
return _Radio(context,
value: d[0] as String,
groupValue: groupValue,
label: d[1] as String,
onChanged: onChanged);
}).toList(),
);
}
Widget otherRow(String label, String key) {
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
onChanged(bool b) async {
await setOtherDefaultSettingOption(
key,
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
);
setState(() {});
}
return GestureDetector(
child: Row(
children: [
Checkbox(
value: value,
onChanged: isOptFixed ? null : (_) => onChanged(!value))
.marginOnly(right: 5),
Expanded(
child: Text(translate(label)),
)
],
).marginOnly(left: _kCheckBoxLeftMargin),
onTap: isOptFixed ? null : () => onChanged(!value));
}
Widget other(BuildContext context) {
final children =
otherDefaultSettings().map((e) => otherRow(e.$1, e.$2)).toList();
return _Card(title: 'Other Default Options', children: children);
}
}
class _Account extends StatefulWidget {
const _Account({Key? key}) : super(key: key);
@override
State<_Account> createState() => _AccountState();
}
class _AccountState extends State<_Account> {
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
return ListView(
controller: scrollController,
children: [
_Card(title: 'Account', children: [accountAction(), useInfo()]),
],
).marginOnly(bottom: _kListViewBottomMargin);
}
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
: logOutConfirmDialog()
}));
}
Widget useInfo() {
return Obx(() => Offstage(
offstage: gFFI.userModel.userName.value.isEmpty,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Builder(builder: (context) {
final avatarWidget = _buildUserAvatar();
return Row(
children: [
if (avatarWidget != null) avatarWidget,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
gFFI.userModel.displayNameOrUserName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
SelectionArea(
child: Text(
'@${gFFI.userModel.userName.value}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color:
Theme.of(context).textTheme.bodySmall?.color,
),
),
),
],
),
),
],
);
}),
),
)).marginOnly(left: 18, top: 16);
}
Widget? _buildUserAvatar() {
// Resolve relative avatar path at display time
final avatar =
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 44,
);
}
}
class _Checkbox extends StatefulWidget {
final String label;
final bool Function() getValue;
final Future<void> Function(bool) setValue;
const _Checkbox(
{Key? key,
required this.label,
required this.getValue,
required this.setValue})
: super(key: key);
@override
State<_Checkbox> createState() => _CheckboxState();
}
class _CheckboxState extends State<_Checkbox> {
var value = false;
@override
initState() {
super.initState();
value = widget.getValue();
}
@override
Widget build(BuildContext context) {
onChanged(bool b) async {
await widget.setValue(b);
setState(() {
value = widget.getValue();
});
}
return GestureDetector(
child: Row(
children: [
Checkbox(
value: value,
onChanged: (_) => onChanged(!value),
).marginOnly(right: 5),
Expanded(
child: Text(translate(widget.label)),
)
],
).marginOnly(left: _kCheckBoxLeftMargin),
onTap: () => onChanged(!value),
);
}
}
class _Printer extends StatefulWidget {
const _Printer({super.key});
@override
State<_Printer> createState() => __PrinterState();
}
class __PrinterState extends State<_Printer> {
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
return ListView(controller: scrollController, children: [
outgoing(context),
incoming(context),
]).marginOnly(bottom: _kListViewBottomMargin);
}
Widget outgoing(BuildContext context) {
final isSupportPrinterDriver =
bind.mainGetCommonSync(key: 'is-support-printer-driver') == 'true';
Widget tipOsNotSupported() {
return Align(
alignment: Alignment.topLeft,
child: Text(translate('printer-os-requirement-tip')),
).marginOnly(left: _kCardLeftMargin);
}
Widget tipClientNotInstalled() {
return Align(
alignment: Alignment.topLeft,
child:
Text(translate('printer-requires-installed-{$appName}-client-tip')),
).marginOnly(left: _kCardLeftMargin);
}
Widget tipPrinterNotInstalled() {
final failedMsg = ''.obs;
platformFFI.registerEventHandler(
'install-printer-res', 'install-printer-res', (evt) async {
if (evt['success'] as bool) {
setState(() {});
} else {
failedMsg.value = evt['msg'] as String;
}
}, replace: true);
return Column(children: [
Obx(
() => failedMsg.value.isNotEmpty
? Offstage()
: Align(
alignment: Alignment.topLeft,
child: Text(translate('printer-{$appName}-not-installed-tip'))
.marginOnly(bottom: 10.0),
),
),
Obx(
() => failedMsg.value.isEmpty
? Offstage()
: Align(
alignment: Alignment.topLeft,
child: Text(failedMsg.value,
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.red))
.marginOnly(bottom: 10.0)),
),
_Button('Install {$appName} Printer', () {
failedMsg.value = '';
bind.mainSetCommon(key: 'install-printer', value: '');
})
]).marginOnly(left: _kCardLeftMargin, bottom: 2.0);
}
Widget tipReady() {
return Align(
alignment: Alignment.topLeft,
child: Text(translate('printer-{$appName}-ready-tip')),
).marginOnly(left: _kCardLeftMargin);
}
final installed = bind.mainIsInstalled();
// `is-printer-installed` may fail, but it's rare case.
// Add additional error message here if it's really needed.
final isPrinterInstalled =
bind.mainGetCommonSync(key: 'is-printer-installed') == 'true';
final List<Widget> children = [];
if (!isSupportPrinterDriver) {
children.add(tipOsNotSupported());
} else {
children.addAll([
if (!installed) tipClientNotInstalled(),
if (installed && !isPrinterInstalled) tipPrinterNotInstalled(),
if (installed && isPrinterInstalled) tipReady()
]);
}
return _Card(title: 'Outgoing Print Jobs', children: children);
}
Widget incoming(BuildContext context) {
onRadioChanged(String value) async {
await bind.mainSetLocalOption(
key: kKeyPrinterIncomingJobAction, value: value);
setState(() {});
}
PrinterOptions printerOptions = PrinterOptions.load();
return _Card(title: 'Incoming Print Jobs', children: [
_Radio(context,
value: kValuePrinterIncomingJobDismiss,
groupValue: printerOptions.action,
label: 'Dismiss',
onChanged: onRadioChanged),
_Radio(context,
value: kValuePrinterIncomingJobDefault,
groupValue: printerOptions.action,
label: 'use-the-default-printer-tip',
onChanged: onRadioChanged),
_Radio(context,
value: kValuePrinterIncomingJobSelected,
groupValue: printerOptions.action,
label: 'use-the-selected-printer-tip',
onChanged: onRadioChanged),
if (printerOptions.printerNames.isNotEmpty)
ComboBox(
initialKey: printerOptions.printerName,
keys: printerOptions.printerNames,
values: printerOptions.printerNames,
enabled: printerOptions.action == kValuePrinterIncomingJobSelected,
onChanged: (value) async {
await bind.mainSetLocalOption(
key: kKeyPrinterSelected, value: value);
setState(() {});
},
).marginOnly(left: 10),
_OptionCheckBox(
context,
'auto-print-tip',
kKeyPrinterAllowAutoPrint,
isServer: false,
enabled: printerOptions.action != kValuePrinterIncomingJobDismiss,
)
]);
}
}
class _About extends StatefulWidget {
const _About({Key? key}) : super(key: key);
@override
State<_About> createState() => _AboutState();
}
class _AboutState extends State<_About> {
@override
Widget build(BuildContext context) {
return futureBuilder(future: () async {
final license = await bind.mainGetLicense();
final version = await bind.mainGetVersion();
final buildDate = await bind.mainGetBuildDate();
final fingerprint = await bind.mainGetFingerprint();
final myId = await bind.mainGetMyId();
return {
'license': license,
'version': version,
'buildDate': buildDate,
'fingerprint': fingerprint,
'myId': myId
};
}(), hasData: (data) {
final license = data['license'].toString();
final version = data['version'].toString();
final buildDate = data['buildDate'].toString();
final fingerprint = data['fingerprint'].toString();
final myId = data['myId'].toString();
const linkStyle = TextStyle(decoration: TextDecoration.underline);
final scrollController = ScrollController();
return SingleChildScrollView(
controller: scrollController,
child: _Card(title: translate('About RustDesk'), children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 8.0,
),
SelectionArea(
child: Text('${translate('Version')}: $version')
.marginSymmetric(vertical: 4.0)),
SelectionArea(
child: Text('${translate('Build Date')}: $buildDate')
.marginSymmetric(vertical: 4.0)),
if (!isWeb)
SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)),
SelectionArea(
child: Text('${translate('ID')}: $myId')
.marginSymmetric(vertical: 4.0)),
InkWell(
onTap: () {
launchUrlString('https://rustdesk.com/privacy.html');
},
child: Text(
translate('Privacy Statement'),
style: linkStyle,
).marginSymmetric(vertical: 4.0)),
InkWell(
onTap: () {
launchUrlString('https://rustdesk.com');
},
child: Text(
translate('Website'),
style: linkStyle,
).marginSymmetric(vertical: 4.0)),
Container(
decoration: const BoxDecoration(color: Color(0xFF2c8cff)),
padding:
const EdgeInsets.symmetric(vertical: 24, horizontal: 8),
child: SelectionArea(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
style: const TextStyle(color: Colors.white),
),
Text(
translate('Slogan_tip'),
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white),
)
],
),
),
],
)),
).marginSymmetric(vertical: 4.0)
],
).marginOnly(left: _kContentHMargin)
]),
);
});
}
}
//#endregion
//#region components
// ignore: non_constant_identifier_names
Widget _Card(
{required String title,
required List<Widget> children,
List<Widget>? title_suffix}) {
return Row(
children: [
Flexible(
child: SizedBox(
width: _kCardFixedWidth,
child: Card(
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
translate(title),
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: _kTitleFontSize,
),
)),
...?title_suffix
],
).marginOnly(left: _kContentHMargin, top: 10, bottom: 10),
...children
.map((e) => e.marginOnly(top: 4, right: _kContentHMargin)),
],
).marginOnly(bottom: 10),
).marginOnly(left: _kCardLeftMargin, top: 15),
),
),
],
);
}
// ignore: non_constant_identifier_names
Widget _OptionCheckBox(
BuildContext context,
String label,
String key, {
Function(bool)? update,
bool reverse = false,
bool enabled = true,
Icon? checkedIcon,
bool? fakeValue,
bool isServer = true,
bool Function()? optGetter,
Future<void> Function(String, bool)? optSetter,
}) {
getOpt() => optGetter != null
? optGetter()
: (isServer
? mainGetBoolOptionSync(key)
: mainGetLocalBoolOptionSync(key));
bool value = getOpt();
final isOptFixed = isOptionFixed(key);
if (reverse) value = !value;
var ref = value.obs;
onChanged(option) async {
if (option != null) {
if (reverse) option = !option;
final setter =
optSetter ?? (isServer ? mainSetBoolOption : mainSetLocalBoolOption);
await setter(key, option);
final readOption = getOpt();
if (reverse) {
ref.value = !readOption;
} else {
ref.value = readOption;
}
update?.call(readOption);
}
}
if (fakeValue != null) {
ref.value = fakeValue;
enabled = false;
}
return GestureDetector(
child: Obx(
() => Row(
children: [
Checkbox(
value: ref.value,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Offstage(
offstage: !ref.value || checkedIcon == null,
child: checkedIcon?.marginOnly(right: 5),
),
Expanded(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
),
).marginOnly(left: _kCheckBoxLeftMargin),
onTap: enabled && !isOptFixed
? () {
onChanged(!ref.value);
}
: null,
);
}
// ignore: non_constant_identifier_names
Widget _Radio<T>(BuildContext context,
{required T value,
required T groupValue,
required String label,
required Function(T value)? onChanged,
bool autoNewLine = true}) {
final onChange2 = onChanged != null
? (T? value) {
if (value != null) {
onChanged(value);
}
}
: null;
return GestureDetector(
child: Row(
children: [
Radio<T>(value: value, groupValue: groupValue, onChanged: onChange2),
Expanded(
child: Text(translate(label),
overflow: autoNewLine ? null : TextOverflow.ellipsis,
style: TextStyle(
fontSize: _kContentFontSize,
color: disabledTextColor(context, onChange2 != null)))
.marginOnly(left: 5),
),
],
).marginOnly(left: _kRadioLeftMargin),
onTap: () => onChange2?.call(value),
);
}
class WaylandCard extends StatefulWidget {
const WaylandCard({Key? key}) : super(key: key);
@override
State<WaylandCard> createState() => _WaylandCardState();
}
class _WaylandCardState extends State<WaylandCard> {
final restoreTokenKey = 'wayland-restore-token';
static const _kClearShortcutsInhibitorEventKey =
'clear-gnome-shortcuts-inhibitor-permission-res';
final _clearShortcutsInhibitorFailedMsg = ''.obs;
// Don't show the shortcuts permission reset button for now.
// Users can change it manually:
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
// For resetting(clearing) the permission from the portal permission store, you can
// use (replace <desktop-id> with the RustDesk desktop file ID):
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
//
// We may add it back in the future if needed.
final showResetInhibitorPermission = false;
@override
void initState() {
super.initState();
if (showResetInhibitorPermission) {
platformFFI.registerEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
(evt) async {
if (!mounted) return;
if (evt['success'] == true) {
setState(() {});
} else {
_clearShortcutsInhibitorFailedMsg.value =
evt['msg'] as String? ?? 'Unknown error';
}
});
}
}
@override
void dispose() {
if (showResetInhibitorPermission) {
platformFFI.unregisterEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return futureBuilder(
future: bind.mainHandleWaylandScreencastRestoreToken(
key: restoreTokenKey, value: "get"),
hasData: (restoreToken) {
final hasShortcutsPermission = showResetInhibitorPermission &&
bind.mainGetCommonSync(
key: "has-gnome-shortcuts-inhibitor-permission") ==
"true";
final children = [
if (restoreToken.isNotEmpty)
_buildClearScreenSelection(context, restoreToken),
if (hasShortcutsPermission)
_buildClearShortcutsInhibitorPermission(context),
];
return Offstage(
offstage: children.isEmpty,
child: _Card(title: 'Wayland', children: children),
);
},
);
}
Widget _buildClearScreenSelection(BuildContext context, String restoreToken) {
onConfirm() async {
final msg = await bind.mainHandleWaylandScreencastRestoreToken(
key: restoreTokenKey, value: "clear");
gFFI.dialogManager.dismissAll();
if (msg.isNotEmpty) {
msgBox(gFFI.sessionId, 'custom-nocancel', 'Error', msg, '',
gFFI.dialogManager);
} else {
setState(() {});
}
}
showConfirmMsgBox() => msgBoxCommon(
gFFI.dialogManager,
'Confirmation',
Text(
translate('confirm_clear_Wayland_screen_selection_tip'),
),
[
dialogButton('OK', onPressed: onConfirm),
dialogButton('Cancel',
onPressed: () => gFFI.dialogManager.dismissAll())
]);
return _Button(
'Clear Wayland screen selection',
showConfirmMsgBox,
tip: 'clear_Wayland_screen_selection_tip',
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
Theme.of(context).colorScheme.error.withOpacity(0.75)),
),
);
}
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
onConfirm() {
_clearShortcutsInhibitorFailedMsg.value = '';
bind.mainSetCommon(
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
gFFI.dialogManager.dismissAll();
}
showConfirmMsgBox() => msgBoxCommon(
gFFI.dialogManager,
'Confirmation',
Text(
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
),
[
dialogButton('OK', onPressed: onConfirm),
dialogButton('Cancel',
onPressed: () => gFFI.dialogManager.dismissAll())
]);
return Column(children: [
Obx(
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
? Offstage()
: Align(
alignment: Alignment.topLeft,
child: Text(_clearShortcutsInhibitorFailedMsg.value,
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.red))
.marginOnly(bottom: 10.0)),
),
_Button(
'Reset keyboard shortcuts permission',
showConfirmMsgBox,
tip: 'clear-shortcuts-inhibitor-permission-tip',
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
Theme.of(context).colorScheme.error.withOpacity(0.75)),
),
),
]);
}
}
// ignore: non_constant_identifier_names
Widget _Button(String label, Function() onPressed,
{bool enabled = true, String? tip, ButtonStyle? style}) {
var button = ElevatedButton(
onPressed: enabled ? onPressed : null,
child: Text(
translate(label),
).marginSymmetric(horizontal: 15),
style: style,
);
StatefulWidget child;
if (tip == null) {
child = button;
} else {
child = Tooltip(message: translate(tip), child: button);
}
return Row(children: [
child,
]).marginOnly(left: _kContentHMargin);
}
// ignore: non_constant_identifier_names
Widget _SubButton(String label, Function() onPressed, [bool enabled = true]) {
return Row(
children: [
ElevatedButton(
onPressed: enabled ? onPressed : null,
child: Text(
translate(label),
).marginSymmetric(horizontal: 15),
),
],
).marginOnly(left: _kContentHSubMargin);
}
// ignore: non_constant_identifier_names
Widget _SubLabeledWidget(BuildContext context, String label, Widget child,
{bool enabled = true}) {
return Row(
children: [
Text(
'${translate(label)}: ',
style: TextStyle(color: disabledTextColor(context, enabled)),
),
SizedBox(
width: 10,
),
child,
],
).marginOnly(left: _kContentHSubMargin);
}
Widget _lock(
bool locked,
String label,
Function() onUnlock,
) {
return Offstage(
offstage: !locked,
child: Row(
children: [
Flexible(
child: SizedBox(
width: _kCardFixedWidth,
child: Card(
child: ElevatedButton(
child: SizedBox(
height: 25,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.security_sharp,
size: 20,
),
Text(translate(label)).marginOnly(left: 5),
]).marginSymmetric(vertical: 2)),
onPressed: () async {
final unlockPin = bind.mainGetUnlockPin();
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
bool checked = await callMainCheckSuperUserPermission();
if (checked) {
onUnlock();
}
} else {
checkUnlockPinDialog(unlockPin, onUnlock);
}
},
).marginSymmetric(horizontal: 2, vertical: 4),
).marginOnly(left: _kCardLeftMargin),
).marginOnly(top: 10),
),
],
));
}
_LabeledTextField(
BuildContext context,
String label,
TextEditingController controller,
String errorText,
bool enabled,
bool secure) {
return Table(
columnWidths: const {
0: FixedColumnWidth(150),
1: FlexColumnWidth(),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
TableRow(
children: [
Padding(
padding: const EdgeInsets.only(right: 10),
child: Text(
'${translate(label)}:',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 16,
color: disabledTextColor(context, enabled),
),
),
),
TextField(
controller: controller,
enabled: enabled,
obscureText: secure,
autocorrect: false,
decoration: InputDecoration(
errorText: errorText.isNotEmpty ? errorText : null,
),
style: TextStyle(
color: disabledTextColor(context, enabled),
),
).workaroundFreezeLinuxMint(),
],
),
],
).marginOnly(bottom: 8);
}
class _CountDownButton extends StatefulWidget {
_CountDownButton({
Key? key,
required this.text,
required this.second,
required this.onPressed,
}) : super(key: key);
final String text;
final VoidCallback? onPressed;
final int second;
@override
State<_CountDownButton> createState() => _CountDownButtonState();
}
class _CountDownButtonState extends State<_CountDownButton> {
bool _isButtonDisabled = false;
late int _countdownSeconds = widget.second;
Timer? _timer;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startCountdownTimer() {
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
if (_countdownSeconds <= 0) {
setState(() {
_isButtonDisabled = false;
});
timer.cancel();
} else {
setState(() {
_countdownSeconds--;
});
}
});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isButtonDisabled
? null
: () {
widget.onPressed?.call();
setState(() {
_isButtonDisabled = true;
_countdownSeconds = widget.second;
});
_startCountdownTimer();
},
child: Text(
_isButtonDisabled ? '$_countdownSeconds s' : translate(widget.text),
),
);
}
}
//#endregion
//#region dialogs
void changeSocks5Proxy() async {
var socks = await bind.mainGetSocks();
String proxy = '';
String proxyMsg = '';
String username = '';
String password = '';
if (socks.length == 3) {
proxy = socks[0];
username = socks[1];
password = socks[2];
}
var proxyController = TextEditingController(text: proxy);
var userController = TextEditingController(text: username);
var pwdController = TextEditingController(text: password);
RxBool obscure = true.obs;
// proxy settings
// The following option is a not real key, it is just used for custom client advanced settings.
const String optionProxyUrl = "proxy-url";
final isOptFixed = isOptionFixed(optionProxyUrl);
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
setState(() {
proxyMsg = '';
isInProgress = true;
});
cancel() {
setState(() {
isInProgress = false;
});
}
proxy = proxyController.text.trim();
username = userController.text.trim();
password = pwdController.text.trim();
if (proxy.isNotEmpty) {
String domainPort = proxy;
if (domainPort.contains('://')) {
domainPort = domainPort.split('://')[1];
}
proxyMsg = translate(await bind.mainTestIfValidServer(
server: domainPort, testWithProxy: false));
if (proxyMsg.isEmpty) {
// ignore
} else {
cancel();
return;
}
}
await bind.mainSetSocks(
proxy: proxy, username: username, password: password);
close();
}
return CustomAlertDialog(
title: Text(translate('Socks5/Http(s) Proxy')),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Align(
alignment: Alignment.centerRight,
child: Row(
children: [
Text(
translate('Server'),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 0),
message: translate("default_proxy_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
),
),
],
)).marginOnly(right: 10),
),
Expanded(
child: TextField(
decoration: InputDecoration(
errorText: proxyMsg.isNotEmpty ? proxyMsg : null,
labelText: isMobile ? translate('Server') : null,
helperText:
isMobile ? translate("default_proxy_tip") : null,
helperMaxLines: isMobile ? 3 : null,
),
controller: proxyController,
autofocus: true,
enabled: !isOptFixed,
).workaroundFreezeLinuxMint(),
),
],
).marginOnly(bottom: 8),
Row(
children: [
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Username")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
Expanded(
child: TextField(
controller: userController,
decoration: InputDecoration(
labelText: isMobile ? translate('Username') : null,
),
enabled: !isOptFixed,
).workaroundFreezeLinuxMint(),
),
],
).marginOnly(bottom: 8),
Row(
children: [
if (!isMobile)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 140),
child: Text(
'${translate("Password")}:',
textAlign: TextAlign.right,
).marginOnly(right: 10)),
Expanded(
child: Obx(() => TextField(
obscureText: obscure.value,
decoration: InputDecoration(
labelText: isMobile ? translate('Password') : null,
suffixIcon: IconButton(
onPressed: () => obscure.value = !obscure.value,
icon: Icon(obscure.value
? Icons.visibility_off
: Icons.visibility))),
controller: pwdController,
enabled: !isOptFixed,
maxLength: bind.mainMaxEncryptLen(),
).workaroundFreezeLinuxMint()),
),
],
),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress)
const LinearProgressIndicator().marginOnly(top: 8),
],
),
),
actions: [
dialogButton('Cancel', onPressed: close, isOutline: true),
if (!isOptFixed) dialogButton('OK', onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
//#endregion