mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-04 16:35:19 +00:00
* fix: secure trusted devices with host-issued V2 tokens Signed-off-by: fufesou <linlong1266@gmail.com> * fix: preserve trusted-device management and ordinary authentication Restore selected-device deletion using nonsecret record identifiers and acknowledged V2 revocation errors. Preserve password and TOTP authentication when the V2 authority or cached controller credentials are unavailable. Disable V2 for the affected attempt while retaining authentication, configuration, and revocation rejection. Validation: 20 selected Rust tests, Flutter-feature Rust compilation, focused Dart analysis, formatting, and whitespace checks passed. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: simplify trusted-device caching and stabilize tests Signed-off-by: fufesou <linlong1266@gmail.com> * fix: persist trusted-device grants in host configuration Store host grants in RustDesk2.toml and preserve revocation across configuration writes and service transitions. Use local authority on macOS when no background service is installed. Preserve both configuration imports by checking Config2 eligibility before revocation writes. Cover the password and 2FA import regression alongside the existing trusted-device tests. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: store trusted-device credentials in peer configuration Signed-off-by: fufesou <linlong1266@gmail.com> * fix: minimize trusted-device persistence changes Signed-off-by: fufesou <linlong1266@gmail.com> * refactor: minimize trusted-device integration changes Keep ordinary policy writes on the generic configuration path and document authentication updates. Restore the macOS installer to master, rename the V2 version gate, and relocate the existing client regression test. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: harden trusted-device revocation and persistence Recover corrupt grant data without skipping authentication-write validation, and avoid revoking trust when IPC synchronizes an unchanged salt. Make desktop management IPC async and keep runtime creation at the Flutter worker boundary. Update hbb_common to preserve V2 credentials when configuration reads fail, and extend existing regressions for clear, salt synchronization, and persistence failures. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: preserve trusted-device validation and no-op settings Validate trust before connection side effects and retain tickets for the final check when the authority is unavailable after TOTP. Disable enrollment on that path. Skip revocation for password operations that the existing setter treats as no-ops, extend the existing IPC regression, and clean up client state on initialization or credential errors. Validation: 36 targeted native tests, Flutter-feature Rust check, formatting, and git diff --check passed. Signed-off-by: fufesou <linlong1266@gmail.com> * feat: add trusted-device expiry and per-device renewal Keep legacy records for password-and-TOTP migration, encrypt host grant storage, and add configurable trust periods that revoke grants when changed. Allow renewing individual unexpired devices from the manager, hide renewal for never-expiring grants, and preserve the mobile table height constraints. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(macos): report trusted-device cleanup failure before uninstall Revoke local trusted-device grants before dispatching the uninstall closure so cleanup failures return false without starting the uninstall script. Keep the existing synchronous and asynchronous script execution paths. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: preserve trusted-device settings and revoke macOS service grants Default unsupported expiration values on read while keeping explicit edits strict. Allow password/TOTP when stored grants are out of sync, after validating the authentication scope. Revoke root-owned grants during macOS service removal. Bump hbb_common for malformed-file recovery and legacy-record preservation, and retain the reviewed regression coverage. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: avoid duplicate trusted-device revocation during config sync Signed-off-by: fufesou <linlong1266@gmail.com> * fix: preserve trusted-device revocation during config mismatches Allow grant clearing when authentication snapshots are unavailable and validate existing snapshots before applying authentication updates. Restore optimistic caching for non-auth bulk options and restrict the trusted-device IPC helper to desktop targets. Signed-off-by: fufesou <linlong1266@gmail.com> * feat: preserve trusted devices when trust duration changes Adjust existing grant deadlines for duration changes and startup policy overrides, support 90 days, and use an adaptive inline duration selector without the obsolete revocation warning. Consolidate management IPC and trim redundant tests while retaining authentication and revocation coverage. Validation: four existing focused Rust tests and the startup override reproduction passed; Windows default/service and Flutter-feature cargo checks passed. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: finish trusted-device notice cleanup and duration sync Remove the recurring legacy notice, unused response metadata and translation key. Allow duration-only saves with a readable inactive grant scope while preserving storage and authentication errors. Update hbb_common to 040bf3f662ae7422f2f9570701cc8451f7bb1d59, which removes the unused legacy detector. Validation: four existing focused Rust tests passed; Windows default/service and Flutter-feature cargo checks passed. No new tests were added. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: refine trusted-device duration layout Indent the duration row beneath the trusted-device option, add vertical spacing, and compact the desktop selector. Mobile sizing and other dropdowns remain unchanged. Validation: targeted Dart analysis passed with existing informational diagnostics. Actual window rendering was not exercised. Signed-off-by: fufesou <linlong1266@gmail.com> * refact: adjust indentation Signed-off-by: fufesou <linlong1266@gmail.com> * fix: preserve trusted devices across version upgrades Honor existing legacy trust without creating new legacy registrations. Import live records into encrypted grants, apply the current expiration policy, and migrate consenting controllers to V2 tokens while retaining grouped revocation. Advertise V2 capability explicitly and hide enrollment for mixed-version sessions. Update existing coverage and use the paired hbb_common migration API. Signed-off-by: fufesou <linlong1266@gmail.com> * fix: translations Signed-off-by: fufesou <linlong1266@gmail.com> * Align the compact trusted-device duration selector on mobile Signed-off-by: fufesou <linlong1266@gmail.com> * Default trusted devices to 90 days and drop unreleased V2 migrations Signed-off-by: fufesou <linlong1266@gmail.com> * Avoid masking macOS daemon stop failures during uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * restore uninstall.scpt, do not clear trusted devices on uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * Preserve trusted devices when uninstalling services Signed-off-by: fufesou <linlong1266@gmail.com> * Preserve trusted devices when importing host configuration Signed-off-by: fufesou <linlong1266@gmail.com> * Recover from unreadable trusted-device grants Log and persistently revoke invalid or undecryptable V2 payloads so password and TOTP can enroll a new device. Keep configuration read and write errors explicit and extend existing tests to cover recovery and rejection of old tokens. Signed-off-by: fufesou <linlong1266@gmail.com> * Preserve trusted devices across service transitions Run grant operations in the accepting server and synchronize current grants and revocations through the existing service configuration channel. Keep ordinary settings saves from restoring stale credentials and retain root peer verification. Recognize explicit configuration errors as live service responses during IPC probing, so duplicate startup does not remove the active socket. Validated with five trusted-device tests, seven Linux IPC filesystem tests, an isolated IPC regression probe, and Windows Flutter and Linux compilation checks. Installed-service transitions and macOS runtime behavior remain untested. Signed-off-by: fufesou <linlong1266@gmail.com> * Keep trusted-device grants under the service authority Preserve root-owned grants when copying service configuration and route installed-host trust operations through the root service. Keep local authentication edits available when the Linux service is explicitly stopped, then synchronize pending revocation before using service grants. Serialize pending revocation synchronization and cover service copies, stale user snapshots, and stopped-service authentication updates with the existing focused tests. Signed-off-by: fufesou <linlong1266@gmail.com> * Replace same-ID grants when enrolling trusted devices Signed-off-by: fufesou <linlong1266@gmail.com> * Allow macOS security edits after service removal Reuse pending trusted-device revocation for offline password and 2FA changes. Honor pending revocation during installation copies and before root-authority requests and configuration synchronization. Validation: five focused macOS tests, macOS and Windows cargo checks, and targeted formatting checks. Signed-off-by: fufesou <linlong1266@gmail.com> * Allow macOS security edits before daemon reinstallation Detect an absent daemon using retained service ownership instead of the incoming-connections stop flag. General Start resumes connections without reinstalling the daemon, so local authentication edits must continue using pending revocation until installation. Adjust the existing recovery test to check that toggling incoming connections does not make a standalone profile service-owned. Signed-off-by: fufesou <linlong1266@gmail.com> * Allow re-enrollment after invalid controller credentials Ignore malformed or undecryptable stored token payloads with a throttled warning while preserving file access errors and host identity checks. Fresh authorized enrollment can replace the unusable credential. Restore logged, non-propagating IPC errors for non-authentication option changes and extend the existing credential recovery test. Signed-off-by: fufesou <linlong1266@gmail.com> * Update hbb_common to serialize peer deletion Signed-off-by: fufesou <linlong1266@gmail.com> * fix(macos): preserve trusted devices across service removal Hand off the current root grant store to the removing user and restore that user's latest grants on reinstallation. Keep other retained profiles from becoming authoritative, and preserve user settings when the service has no optional configuration. Validate the macOS build, five trusted-device tests, isolated headless transfer cases, and AppleScript syntax. Real privileged service transitions remain untested. Signed-off-by: fufesou <linlong1266@gmail.com> * refactor(trusted-devices): use local grants and existing config sync Keep grant decisions in the accepting host process and transfer grants and removals through the existing background configuration sync. Remove root-only grant RPC, ownership handoff, pending revocation, and dedicated copy commands; restore the master service lifecycle on Linux and macOS. Preserve V2 credentials, authentication revocation, and ordinary-save protections. Accept the existing asynchronous sync and multiple-user limitations. Validated Windows and macOS Flutter checks, the Linux library/tests/service check, four macOS trusted-device tests, and AppleScript syntax. Real service transitions remain untested. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(trusted-devices): preserve migrated tokens at capacity Raise the shared V2 token limit to 128 and order newly migrated tokens by issuance time without extending their legacy expiration. Extend the existing migration regression coverage to verify token reuse and expiry preservation at capacity. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): preserve trust across service stop and start Export current on-disk grants and explicitly synchronize them into the GUI configuration at startup and before stopping a running service. Abort the stop if synchronization fails so the next service import cannot use a stale local copy. Extend the existing synchronization regression coverage to check revoked, retained and newly enrolled devices and the actual import-source file. Remove unrelated import logging changes. Validation: trusted-device tests passed on Windows and macOS (4 each); changed-line formatting and git diff --check passed. Real Windows GUI/UAC service transitions remain untested. Signed-off-by: fufesou <linlong1266@gmail.com> * refactor(trusted-devices): simplify local authorization Replace local authorization envelopes with typed operations while retaining locked authentication checks and persistence. Remove redundant configuration and Dart wrappers without changing credential formats or management requests. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): allow service removal when host is unavailable Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): preserve trust revocations before full uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): sync trust changes to the retained user config Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): sync trust after security setting changes Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
3398 lines
111 KiB
Dart
3398 lines
111 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,
|
|
optSetter: (key, value) async {
|
|
try {
|
|
await mainSetBoolOption(key, value);
|
|
} catch (error) {
|
|
showToast(error.toString());
|
|
}
|
|
},
|
|
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,
|
|
if (mainGetBoolOptionSync(kOptionEnableTrustedDevices))
|
|
trustedDeviceExpiration(
|
|
enabled: !locked,
|
|
onChanged: () {
|
|
if (mounted) setState(() {});
|
|
},
|
|
).marginOnly(
|
|
left: _kCheckBoxLeftMargin + 20 + _kContentHSubMargin,
|
|
top: _kContentHMargin,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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
|