mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-04 08:34:12 +00:00
* fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)
* feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
session actions. Bindings are stored in LocalConfig under
`keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
`pass_through` flags so flipping the master switch off is a hard stop.
Wire-up summary:
- src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
flutter/test/fixtures/default_keyboard_shortcuts.json
- src/keyboard.rs: shortcut intercept in process_event{,_with_session},
feature-gated to `flutter`; runs before key swapping so users bind to
physical keys
- src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
- flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
recording dialog, shortcut display formatter, action group registry
- flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
shells around the shared body
- flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
registerSessionShortcutActions for actions with no toolbar TToggleMenu /
TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
- flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
tagged entries' existing onChanged into the ShortcutModel
- flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
parity (default bindings, supported key vocabulary)
Design principles applied during review:
1. Additions are fine; modifications to original logic must be deliberate.
Tagging an existing TToggleMenu entry with `actionId:` is an addition.
Rewriting its onChanged to satisfy a new contract is a modification —
and was reverted for every case where the original click behavior was
working. Four closures were touched and then reverted (mobile View
Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
wheel); their shortcuts are wired via standalone closures in
shortcut_model.dart instead.
2. Toolbar auto-register is reserved for entries whose onChanged is
inherently self-flipping — typically `sessionToggleOption(name)` where
the named option is flipped in place and the input bool is unused. The
register pass passes `!menu.value` from registration time, which is
harmless under self-flipping but wrong for closures that consume the
input bool directly. Tagging a non-self-flipping entry forces a closure
rewrite; choose non-toolbar registration in that case.
3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
unchanged. The matcher's `enabled`-gate already guarantees no
dispatch; the auto-register pass is left unconditional (its only effect
is HashMap operations on a separate ShortcutModel) so mid-session
enable works without a reconnect. The trade-off is intentional and
documented at the top of toolbarControls.
4. Comments stay terse. Rationale lives in one place — the doc comment of
the helper or registration site, not duplicated at every call site.
5. Where an existing helper needs a new optional behavior (e.g.
`_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
to byte-identical output for existing callers (`trailing == null`
case → original `Expanded(Text)` layout). Verified.
6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
`reset_canvas` so the action ID matches its user-facing label
("Reset canvas") and capability flag.
Out-of-scope but included:
- AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
Web target's hand-written TS client, since both are load-bearing for
any new FFI work.
- remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
* langs
* fix(keyboard): shortcuts, harden config and callback lifecycle
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, color of "Reset to defaults"
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, release keys before shortcut callback
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, debug web
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(keyboard): iPad, icon
Signed-off-by: fufesou <linlong1266@gmail.com>
* langs: add shortcut keys to az, gl, ur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep local modifiers and release keys on the right session
When a chord fired, the shortcut path reused release_remote_keys(), which
replays synthetic releases through process_event(). That cleared the local
MODIFIERS_STATE while the user still held the modifiers, so the next key of
a held chord no longer matched and leaked to the remote, and it sent the
releases to the globally current session instead of the one that typed the
chord.
The shortcut path now releases the remote keys itself: it sends through the
caller's session, and restores the modifiers it released on the remote as
still held locally. The press that fired is remembered until its release,
so its auto repeats and its release are consumed instead of reaching the
remote.
Also state the contract for unavailable actions: a bound chord always
belongs to RustDesk and is a no-op when the session has no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, record the physical key
The recording dialog and the Web Flutter matcher named keys by
LogicalKeyboardKey, while the native matcher sees the physical key through
its USB HID usage and the Web JS matcher through KeyboardEvent.code. On
AZERTY, QWERTZ, Dvorak and similar layouts a recorded binding then did not
fire, or fired from another key.
Name keys by their physical position everywhere: physicalKeyName maps the
USB HID usage to the stored name. A new fixture pins every name to its USB
HID usage, checked on the Dart side and against rdev::usb_hid_key_from_code
on the Rust side, and a test covers AZERTY and QWERTZ events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep an empty binding list on re-enable
setEnabled seeded the defaults whenever the binding list was empty, so a
user who cleared every binding got all defaults back after disable and
enable. Seed only when the config has never held a bindings list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, track every fired key until its release
FIRED_KEY held a single key, so overlapping shortcut keys broke it: with the
chord held, P then C overwrote P, P's release reached the remote, and a P
repeat could fire the screenshot again. A repeat after the modifiers were
released was also treated as a new press and leaked to the remote.
Keep a set of fired keys instead. Once a press fires, every event of that
key belongs to RustDesk until its release: repeats are consumed without
dispatching, whatever the modifiers do, and the release removes the key.
Entries whose release never arrives are dropped when a session view is
entered or a session is closed, not by guessing from the modifiers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep fired keys per session
FIRED_KEYS was process-global while dispatch is per session. Closing or
entering any session cleared the fired keys of all of them, so a key still
held in another session leaked its release to that remote, and a key fired
in one session made the same key in another session look like a repeat.
Key the fired set by session: try_dispatch resolves the session first and
only looks at that session's keys, and session_close clears only the
closing session. Entering a session view no longer clears anything: it is
a pointer enter, and a fired key may still be physically held across it.
A key whose release is missed otherwise heals itself: its next press is
consumed as a repeat and that release removes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(shortcuts): preserve input state and refresh action availability
* fix(keyboard): shortcuts, keep a fired key owned across session close
Close tab is itself a shortcut, so the session that consumed the key press
is gone before the key is released. Both matchers dropped the key with the
session: the Flutter dispatcher removed its own entries in clear(), and the
Rust set was keyed by session and cleared in session_close. The release,
and the repeats while the key stayed held, then reached the next session's
remote as ordinary input.
Ownership of a fired key is physical, so it is now one set shared by every
session on each side, and closing a session no longer touches it: clear()
only drops the pending action and modifier replay, and the Rust session
state only tracks released modifiers. A key whose release is missed heals
itself on its next press. Entering view-only with the Flutter input source
still clears the Rust set, since that source stops routing key events to it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, drop the Toggle Input Source action
The action switches the key capture backend between key down and key up.
Fired-key ownership lives in whichever matcher saw the press, the Flutter
dispatcher or the Rust set, and there is no handoff between them: after the
switch the repeats and the release of the held key reach the other backend,
which forwards them to the remote and, while the chord is held, matches the
action again and toggles the source back. Remove the shortcut instead of
building a handoff for one action. The toolbar radio menu is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* perf(keyboard): shortcuts, parse the config once per stored value
The Flutter matcher read the local config three times and parsed its JSON
three times on every key press in legacy mode, on Web and in view-only.
Keep one parsed snapshot keyed by the raw stored string, so a key press
costs one config read and no parsing while the config is unchanged. The
existing readers are thin views of the same snapshot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, run focus-changing actions on key release
Close tab and tab switching move keyboard focus to another session before
the key that fired them is released. On Linux the Flutter input source
routes map-mode sessions through the Rust matcher and legacy-mode sessions
through the Dart matcher, and neither knows the other's fired keys, so
after such an action the repeats and the release of the held key reached
the next session's remote, and the key stayed recorded as fired in the
matcher that saw the press.
Routing Linux legacy mode through the Rust FFI path instead is not a drop-in
change: the raw key handler builds its event with no unicode, so Rust's
legacy mode would fall back to US key names and lose the layout-aware
characters the Dart path sends today, and it would take modifiers from the
X11 key state instead of Flutter's.
Run these three actions when the fired key is released. The matcher that
consumed the press then also sees the repeats and the release, the remote
modifiers are still released on the press, and a session that closes drops
the action it owed. Both matchers keep the same list of actions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* test(keyboard): shortcuts, pin the key-up action list on both sides
kShortcutActionsRunOnKeyUp and runs_on_release are two hand-kept lists of
the actions that run on key release. Add a shared fixture and check each
side against it, the way the key vocabulary and the default bindings are
already checked, so adding a focus-changing action on one side breaks a
test until the other side follows. The Dart test also checks that every
listed id is a configurable action.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix: prevent lost input after remote session shortcuts
Avoid synthetic Alt presses that activate the Windows system menu
and swallow the next character.
Trigger restart, switch-sides, and chat actions on key release so
focus changes cannot leave subsequent shortcut presses ignored.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(mobile): correct Chat keyboard and input helper behavior
- Keep soft keyboard and helper hidden when Chat opens via shortcut
- Restore soft input on touch and remote focus after Chat closes
- Handle modifier releases while Chat owns focus
- Clear hidden virtual modifiers while preserving held keys and Pin behavior
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): prevent recording non-simultaneous key combinations
Build new captures from currently held keys instead of reusing released
letters. Preserve the last valid chord after release so it can be saved.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): isolate window actions and prevent duplicate Alt presses
- Dispatch shortcut actions only to the originating session window.
- Clear local modifier state on focus loss without resending modifiers
already released to Windows peers, preventing lost following input.
- Strengthen the regression assertion against extra Alt keydown events
Signed-off-by: fufesou <linlong1266@gmail.com>
* refact(shortcuts): empty default shortcuts for Cmd+Option+Shift+Del on macOS and iOS
Because Del may be ambiguous for new macOS users.
Cmd+Option+Shift+Backspace is "Empty Trash without confirmation" shortcut on macOS.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: use session peer platform for Ctrl+Alt+Del events
Fix Android-to-Linux Ctrl+Alt+Del delivery by using the session's peer
platform instead of the desktop global session, which defaults to Windows
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: hide relative mouse mode shortcuts on Wayland
Separate relative mouse mode availability from screenshot support.
Filter unsupported actions and defaults while preserving saved bindings
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: align shortcut availability with platform support
- Hide unsupported mobile and web shortcut actions
- Enable mobile scaling and Android voice call shortcuts
- Use openMonitorInTheSameTab() for display switching
- Preserve cursor position when switching displays on desktop and web
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent held-key shortcut recording bypass
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: preserve keyboard input after shortcut dialogs
Run native clipboard shortcuts on key release and wait for remote modifier releases before dispatching.
Release remote modifiers when mobile local UI takes focus, and document the tested iPad Bluetooth Mute shortcut limitation.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: preserve remote key state during local shortcuts
Keep unrelated held keys pressed for session actions that retain input. Preserve key release when focus or input handling changes, and avoid synthetic Alt taps during shortcut dispatch.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: suspend saved relative mouse shortcuts on Wayland
Filter the native active binding cache without changing stored configuration, so the binding becomes available again under X11.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude unsupported Wayland shortcuts from Flutter matching
Keep saved bindings intact while filtering the active config used by the Flutter matcher. Cover stored bindings and enable/pass-through flags in the existing shortcut tests.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: retain forwarded key ownership across shortcut repeats
Ignore repeat presses for keys already forwarded to the peer, and release ordinary held keys before Show My Cursor enables view-only. Cover both paths with converter-level regressions.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: release Flutter-held keys before view-only shortcuts
Track ordinary keys forwarded by the Dart matcher paths and await their releases before View Only or Show My Cursor disables input. Keep Mute key ownership and existing empty-cleanup timing unchanged.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: filter active Flutter shortcuts by platform capabilities
Reuse the Settings action filter for saved bindings while preserving storage, default-seeding behavior and configurable Apple Ctrl+Alt+Del bindings.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: filter native shortcut caches for mobile controllers
Suspend saved desktop-only actions on Android and iOS and hide unavailable iOS actions without changing persisted bindings. Keep native cache filtering aligned with Flutter platform capabilities.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: share filtered shortcut bindings with the Web matcher
Pass the active platform config to JavaScript at startup and after settings changes, preserving unsupported saved bindings in local storage.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: release Flutter-held keys before focus-changing shortcuts
Drain ordinary keys owned by the original session before run-on-key-up
actions and native clipboard callbacks. Preserve Mute holds, pending-action
cancellation and Web clipboard key-down activation.
Document the required Web matcher counterpart in the bridge:
rustdesk/rustdesk-web-js@94abe431d6
Validation: 61 native Flutter tests, 32 Chrome/HTML tests, targeted analysis
and Windows debug build.
Signed-off-by: fufesou <linlong1266@gmail.com>
* refact: remove the useless comments
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent Windows menu activation from Alt-only shortcuts
Neutralize the forwarded Alt with a balanced temporary Ctrl press when a consumed local shortcut releases its only modifier in Windows Map mode. Keep physical modifier tracking and normal Alt pass-through unchanged.
Add a regression covering both Alt keys, trigger repeats and the eventual physical release. Verified with 36 native shortcut tests and real Windows menu/input checks.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent Alt menu activation in all keyboard modes
Extend Windows Alt-only shortcut neutralization to Translate and Legacy. Keep the temporary Ctrl packets in Map mode so Legacy modifier synchronization cannot release Alt before Ctrl.
Extend the existing regression across keyboard modes and decode scan, virtual and control-key events. Verify 36 native tests and 34 Windows menu replay cases.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: initialize outgoing chat before shortcut dispatch
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: guard Windows Alt release in Flutter legacy shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: coordinate Web shortcut dispatch and Alt modifier cleanup
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: distinguish AltGraph from ordinary Alt shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: dispatch macOS Web shortcuts without waiting for keyup
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: support legacy peers in privacy mode shortcut
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude AltGraph from Flutter shortcut matching
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude GTK AltGr input from raw shortcut matching
Signed-off-by: fufesou <linlong1266@gmail.com>
* test: trim redundant keyboard shortcut coverage
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(keyboard): prevent Cmd shortcuts from opening remote Start
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): guard Windows menu keys after Ctrl/Cmd swapping
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): align recording and display routing
Reject non-Apple AltGraph captures to match runtime filtering. Honor individual-display windows for next, previous, and all-display shortcuts.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): reject GTK AltGr chords in recorder
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(android): restore keyboard focus for modifier-first input
Focus the existing FlutterView when a modifier is pressed without native view focus. This restores shortcut-first input after returning to RustDesk while preserving focused inputs and normal key dispatch.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): release held keys before keyboard mode changes
Drain Flutter-forwarded keys before keyboard-mode shortcut actions so releases use the original encoding. This prevents held keys from remaining down after a Web Input source 2 mode change.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(i18n): use sentence case for shortcut labels
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(mobile): preserve touch modifiers in the input helper
Keep touch-selected modifiers visible after hiding the keyboard. Clear physical modifier releases locally in view-only mode so they do not reopen the helper.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(web): preserve modifiers across Legacy-to-Map shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): check support before registering all displays view
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3377 lines
110 KiB
Dart
3377 lines
110 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/common/widgets/keyboard_shortcuts/page_body.dart';
|
|
import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_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/shortcut_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(),
|
|
if (!bind.isIncomingOnly()) keyboardShortcuts(),
|
|
],
|
|
).marginOnly(bottom: _kListViewBottomMargin);
|
|
}
|
|
|
|
Widget keyboardShortcuts() {
|
|
// The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three
|
|
// flags + the bindings list: {enabled, pass_through, bindings}. When the
|
|
// master is off, the pass-through toggle and the Configure entry are
|
|
// hidden — both are meaningless without an active matcher.
|
|
return StatefulBuilder(builder: (context, setLocalState) {
|
|
final enabled = ShortcutModel.isEnabled();
|
|
return _Card(title: 'Keyboard shortcuts', children: [
|
|
_OptionCheckBox(
|
|
context,
|
|
'Enable keyboard shortcuts in remote session',
|
|
kShortcutLocalConfigKey,
|
|
isServer: false,
|
|
optGetter: ShortcutModel.isEnabled,
|
|
optSetter: (_, v) async {
|
|
await ShortcutModel.setEnabled(v);
|
|
setLocalState(() {});
|
|
},
|
|
),
|
|
if (enabled) ...[
|
|
_OptionCheckBox(
|
|
context,
|
|
'Pass-through to remote',
|
|
kShortcutLocalConfigKey,
|
|
isServer: false,
|
|
optGetter: ShortcutModel.isPassThrough,
|
|
optSetter: (_, v) async {
|
|
await ShortcutModel.setPassThrough(v);
|
|
setLocalState(() {});
|
|
},
|
|
trailing: const InfoTooltipIcon(tipKey: 'shortcut-passthrough-tip'),
|
|
),
|
|
_ShortcutsConfigureRow(),
|
|
],
|
|
]);
|
|
});
|
|
}
|
|
|
|
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 TCP hole punching',
|
|
kOptionEnableTcpPunch,
|
|
isServer: false,
|
|
),
|
|
_OptionCheckBox(
|
|
context,
|
|
'Enable UDP hole punching',
|
|
kOptionEnableUdpPunch,
|
|
isServer: false,
|
|
),
|
|
_OptionCheckBox(
|
|
context,
|
|
'Enable IPv6 P2P connection',
|
|
kOptionEnableIpv6Punch,
|
|
isServer: false,
|
|
),
|
|
],
|
|
if (!incomingOnly) ...webrtcOptions(context),
|
|
if (!isWeb && !incomingOnly)
|
|
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);
|
|
});
|
|
}
|
|
|
|
// How long an already-connected relay is held back to give the direct WebRTC
|
|
// attempt a chance to win. It only means anything while WebRTC is on, so it
|
|
// follows the checkbox as an indented sub-option and is hidden outright when
|
|
// the box is clear — the shape `directIp` uses for its port.
|
|
List<Widget> webrtcOptions(BuildContext context) {
|
|
final stored = bind.mainGetLocalOption(key: kOptionRelayFallbackDelay);
|
|
final controller = TextEditingController(text: stored);
|
|
// What the field holds against what is saved. Apply is offered only while
|
|
// the two differ, so an untouched field shows no button at all, and neither
|
|
// does one typed back to its saved value or cleared when nothing was saved
|
|
// — the state an "edited" flag alone would still call dirty.
|
|
final typed = RxString(stored);
|
|
final saved = RxString(stored);
|
|
return [
|
|
_OptionCheckBox(
|
|
context,
|
|
'Enable WebRTC P2P connection',
|
|
kOptionEnableWebrtc,
|
|
isServer: false,
|
|
update: (_) => setState(() {}),
|
|
),
|
|
() {
|
|
final enabled = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
|
final isOptFixed = isOptionFixed(kOptionRelayFallbackDelay);
|
|
return Offstage(
|
|
offstage: !enabled,
|
|
child: Tooltip(
|
|
message: translate('relay-fallback-delay-tip'),
|
|
child: _SubLabeledWidget(
|
|
context,
|
|
'Relay fallback delay in seconds',
|
|
Row(children: [
|
|
SizedBox(
|
|
width: 95,
|
|
child: TextField(
|
|
controller: controller,
|
|
enabled: enabled && !isOptFixed,
|
|
onChanged: (v) => typed.value = v,
|
|
inputFormatters: [
|
|
// Seconds, at most one decimal. Clearing the field is
|
|
// allowed and restores the built-in default.
|
|
FilteringTextInputFormatter.allow(
|
|
RegExp(r'^([0-9]|[1-9][0-9])(\.[0-9]?)?$')),
|
|
],
|
|
decoration: const InputDecoration(
|
|
hintText: '2.5',
|
|
contentPadding:
|
|
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
),
|
|
).workaroundFreezeLinuxMint().marginOnly(right: 15),
|
|
),
|
|
Obx(() => Offstage(
|
|
offstage: typed.value.trim() == saved.value.trim(),
|
|
child: ElevatedButton(
|
|
onPressed: enabled &&
|
|
!isOptFixed &&
|
|
!typed.value.trim().endsWith('.') &&
|
|
double.tryParse(typed.value.trim()) != 0
|
|
? () async {
|
|
final v = controller.text.trim();
|
|
await bind.mainSetLocalOption(
|
|
key: kOptionRelayFallbackDelay, value: v);
|
|
if (controller.text != v) controller.text = v;
|
|
typed.value = v;
|
|
saved.value = v;
|
|
}
|
|
: null,
|
|
child: Text(translate('Apply')),
|
|
),
|
|
))
|
|
]),
|
|
enabled: enabled && !isOptFixed,
|
|
),
|
|
),
|
|
);
|
|
}(),
|
|
];
|
|
}
|
|
}
|
|
|
|
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,
|
|
// Optional widget rendered between the label and the trailing space.
|
|
Widget? trailing,
|
|
}) {
|
|
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),
|
|
),
|
|
// Without `trailing`, keep the original Expanded(Text) layout.
|
|
if (trailing == null)
|
|
Expanded(
|
|
child: Text(
|
|
translate(label),
|
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
|
))
|
|
else ...[
|
|
Flexible(
|
|
child: Text(
|
|
translate(label),
|
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
|
),
|
|
),
|
|
trailing,
|
|
const Spacer(),
|
|
],
|
|
],
|
|
),
|
|
).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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Tappable row that pushes the shortcut configuration page.
|
|
class _ShortcutsConfigureRow extends StatelessWidget {
|
|
// ignore: unused_element
|
|
const _ShortcutsConfigureRow({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: () {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => const DesktopKeyboardShortcutsPage(),
|
|
));
|
|
},
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(translate('Configure shortcuts...')),
|
|
),
|
|
Icon(Icons.arrow_forward_ios,
|
|
size: 16, color: disabledTextColor(context, true))
|
|
.marginOnly(right: 4),
|
|
],
|
|
).marginOnly(
|
|
left: _kCheckBoxLeftMargin,
|
|
top: 6,
|
|
bottom: 6,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
//#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
|