mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-05 16:38:17 +00:00
* feat: add connections without sending passwords
Add Without password actions and pass the selection through desktop,
mobile and Web sessions. Omit the password when constructing LoginRequest,
preserve saved credentials, and reuse the existing approval and OS login
dialogs. Keep protocol and controlled-side authentication changes separate.
* fix: preserve login method across session windows
* fix: skip password persistence for consent connections
* refactor: unify request approval menus and naming
* chore: move request approval translation key to the end
* i18n: translate request approval menu label
* fix: align request approval menu layout and icons
* fix: avoid duplicate focus stops in request approval menus
* fix: distinguish request approval submenus on mobile
* fix: open overflowing submenus to the left
* Revert "fix: distinguish request approval submenus on mobile"
This reverts commit 7f0e50d4e4.
665 lines
25 KiB
Dart
665 lines
25 KiB
Dart
// main window right pane
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
|
|
import 'package:flutter_hbb/consts.dart';
|
|
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
|
import 'package:flutter_hbb/models/state_model.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
import 'package:flutter_hbb/models/peer_model.dart';
|
|
|
|
import '../../common.dart';
|
|
import '../../common/formatter/id_formatter.dart';
|
|
import '../../common/widgets/peer_tab_page.dart';
|
|
import '../../common/widgets/autocomplete.dart';
|
|
import '../../common/widgets/request_approval_menu.dart';
|
|
import '../../models/platform_model.dart';
|
|
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
|
|
|
|
class OnlineStatusWidget extends StatefulWidget {
|
|
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
|
|
: super(key: key);
|
|
|
|
final VoidCallback? onSvcStatusChanged;
|
|
|
|
@override
|
|
State<OnlineStatusWidget> createState() => _OnlineStatusWidgetState();
|
|
}
|
|
|
|
/// State for the connection page.
|
|
class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
|
|
final _svcStopped = Get.find<RxBool>(tag: 'stop-service');
|
|
final _svcIsUsingPublicServer = true.obs;
|
|
Timer? _updateTimer;
|
|
|
|
double get em => 14.0;
|
|
double? get height => bind.isIncomingOnly() ? null : em * 3;
|
|
|
|
void onUsePublicServerGuide() {
|
|
const url = "https://rustdesk.com/pricing";
|
|
canLaunchUrlString(url).then((can) {
|
|
if (can) {
|
|
launchUrlString(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_updateTimer = periodic_immediate(Duration(seconds: 1), () async {
|
|
updateStatus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_updateTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isIncomingOnly = bind.isIncomingOnly();
|
|
startServiceWidget() => Offstage(
|
|
offstage: !_svcStopped.value,
|
|
child: InkWell(
|
|
onTap: () async {
|
|
await start_service(true);
|
|
},
|
|
child: Text(translate("Start service"),
|
|
style: TextStyle(
|
|
decoration: TextDecoration.underline, fontSize: em)))
|
|
.marginOnly(left: em),
|
|
);
|
|
|
|
setupServerWidget() => Flexible(
|
|
child: Offstage(
|
|
offstage: !(!_svcStopped.value &&
|
|
stateGlobal.svcStatus.value == SvcStatus.ready &&
|
|
_svcIsUsingPublicServer.value),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Text(', ', style: TextStyle(fontSize: em)),
|
|
Flexible(
|
|
child: InkWell(
|
|
onTap: onUsePublicServerGuide,
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
translate('setup_server_tip'),
|
|
style: TextStyle(
|
|
decoration: TextDecoration.underline,
|
|
fontSize: em),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
basicWidget() => Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
height: 8,
|
|
width: 8,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: _svcStopped.value ||
|
|
stateGlobal.svcStatus.value == SvcStatus.connecting
|
|
? kColorWarn
|
|
: (stateGlobal.svcStatus.value == SvcStatus.ready
|
|
? Color.fromARGB(255, 50, 190, 166)
|
|
: Color.fromARGB(255, 224, 79, 95)),
|
|
),
|
|
).marginSymmetric(horizontal: em),
|
|
Container(
|
|
width: isIncomingOnly ? 226 : null,
|
|
child: _buildConnStatusMsg(),
|
|
),
|
|
// stop
|
|
if (!isIncomingOnly) startServiceWidget(),
|
|
// ready && public
|
|
// No need to show the guide if is custom client.
|
|
if (!isIncomingOnly) setupServerWidget(),
|
|
],
|
|
);
|
|
|
|
return Container(
|
|
height: height,
|
|
child: Obx(() => isIncomingOnly
|
|
? Column(
|
|
children: [
|
|
basicWidget(),
|
|
Align(
|
|
child: startServiceWidget(),
|
|
alignment: Alignment.centerLeft)
|
|
.marginOnly(top: 2.0, left: 22.0),
|
|
],
|
|
)
|
|
: basicWidget()),
|
|
).paddingOnly(right: isIncomingOnly ? 8 : 0);
|
|
}
|
|
|
|
_buildConnStatusMsg() {
|
|
widget.onSvcStatusChanged?.call();
|
|
return Text(
|
|
_svcStopped.value
|
|
? translate("Service is not running")
|
|
: stateGlobal.svcStatus.value == SvcStatus.connecting
|
|
? translate("connecting_status")
|
|
: stateGlobal.svcStatus.value == SvcStatus.notReady
|
|
? translate("not_ready_status")
|
|
: translate('Ready'),
|
|
style: TextStyle(fontSize: em),
|
|
);
|
|
}
|
|
|
|
updateStatus() async {
|
|
final status =
|
|
jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
|
|
final statusNum = status['status_num'] as int;
|
|
if (statusNum == 0) {
|
|
stateGlobal.svcStatus.value = SvcStatus.connecting;
|
|
} else if (statusNum == -1) {
|
|
stateGlobal.svcStatus.value = SvcStatus.notReady;
|
|
} else if (statusNum == 1) {
|
|
stateGlobal.svcStatus.value = SvcStatus.ready;
|
|
} else {
|
|
stateGlobal.svcStatus.value = SvcStatus.notReady;
|
|
}
|
|
_svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer();
|
|
try {
|
|
stateGlobal.videoConnCount.value = status['video_conn_count'] as int;
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
/// Connection page for connecting to a remote peer.
|
|
class ConnectionPage extends StatefulWidget {
|
|
const ConnectionPage({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<ConnectionPage> createState() => _ConnectionPageState();
|
|
}
|
|
|
|
/// State for the connection page.
|
|
class _ConnectionPageState extends State<ConnectionPage>
|
|
with SingleTickerProviderStateMixin, WindowListener {
|
|
/// Controller for the id input bar.
|
|
final _idController = IDTextEditingController();
|
|
|
|
final RxBool _idInputFocused = false.obs;
|
|
final FocusNode _idFocusNode = FocusNode();
|
|
final TextEditingController _idEditingController = TextEditingController();
|
|
|
|
String selectedConnectionType = 'Connect';
|
|
|
|
bool isWindowMinimized = false;
|
|
|
|
final AllPeersLoader _allPeersLoader = AllPeersLoader();
|
|
|
|
// https://github.com/flutter/flutter/issues/157244
|
|
Iterable<Peer> _autocompleteOpts = [];
|
|
|
|
final _menuOpen = false.obs;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_allPeersLoader.init(setState);
|
|
_idFocusNode.addListener(onFocusChanged);
|
|
if (_idController.text.isEmpty) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final lastRemoteId = await bind.mainGetLastRemoteId();
|
|
if (lastRemoteId != _idController.id) {
|
|
setState(() {
|
|
_idController.id = lastRemoteId;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
Get.put<TextEditingController>(_idEditingController);
|
|
Get.put<IDTextEditingController>(_idController);
|
|
windowManager.addListener(this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_idController.dispose();
|
|
windowManager.removeListener(this);
|
|
_allPeersLoader.clear();
|
|
_idFocusNode.removeListener(onFocusChanged);
|
|
_idFocusNode.dispose();
|
|
_idEditingController.dispose();
|
|
if (Get.isRegistered<IDTextEditingController>()) {
|
|
Get.delete<IDTextEditingController>();
|
|
}
|
|
if (Get.isRegistered<TextEditingController>()) {
|
|
Get.delete<TextEditingController>();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void onWindowEvent(String eventName) {
|
|
super.onWindowEvent(eventName);
|
|
if (eventName == 'minimize') {
|
|
isWindowMinimized = true;
|
|
} else if (eventName == 'maximize' || eventName == 'restore') {
|
|
if (isWindowMinimized && isWindows) {
|
|
// windows can't update when minimized.
|
|
Get.forceAppUpdate();
|
|
}
|
|
isWindowMinimized = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWindowEnterFullScreen() {
|
|
// Remove edge border by setting the value to zero.
|
|
stateGlobal.resizeEdgeSize.value = 0;
|
|
}
|
|
|
|
@override
|
|
void onWindowLeaveFullScreen() {
|
|
// Restore edge border to default edge size.
|
|
stateGlobal.resizeEdgeSize.value = stateGlobal.isMaximized.isTrue
|
|
? kMaximizeEdgeSize
|
|
: windowResizeEdgeSize;
|
|
}
|
|
|
|
@override
|
|
void onWindowClose() {
|
|
super.onWindowClose();
|
|
bind.mainOnMainWindowClose();
|
|
}
|
|
|
|
void onFocusChanged() {
|
|
_idInputFocused.value = _idFocusNode.hasFocus;
|
|
if (_idFocusNode.hasFocus) {
|
|
if (_allPeersLoader.needLoad) {
|
|
_allPeersLoader.getAllPeers();
|
|
}
|
|
|
|
final textLength = _idEditingController.value.text.length;
|
|
// Select all to facilitate removing text, just following the behavior of address input of chrome.
|
|
_idEditingController.selection =
|
|
TextSelection(baseOffset: 0, extentOffset: textLength);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isOutgoingOnly = bind.isOutgoingOnly();
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(child: _buildRemoteIDTextField(context)),
|
|
],
|
|
).marginOnly(top: 22),
|
|
SizedBox(height: 12),
|
|
Divider().paddingOnly(right: 12),
|
|
Expanded(child: PeerTabPage()),
|
|
],
|
|
).paddingOnly(left: 12.0)),
|
|
if (!isOutgoingOnly) const Divider(height: 1),
|
|
if (!isOutgoingOnly) OnlineStatusWidget()
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Callback for the connect button.
|
|
/// Connects to the selected peer.
|
|
void onConnect(
|
|
{bool isFileTransfer = false,
|
|
bool isViewCamera = false,
|
|
bool isTerminal = false,
|
|
bool isTcpTunneling = false}) {
|
|
var id = _idController.id;
|
|
connect(context, id,
|
|
isFileTransfer: isFileTransfer,
|
|
isViewCamera: isViewCamera,
|
|
isTerminal: isTerminal,
|
|
isTcpTunneling: isTcpTunneling);
|
|
}
|
|
|
|
RequestApprovalMenu _requestApprovalMenu(BuildContext context) {
|
|
MenuEntryButton<String> action(String title,
|
|
{bool isFileTransfer = false,
|
|
bool isViewCamera = false,
|
|
bool isTerminal = false,
|
|
bool isTcpTunneling = false}) =>
|
|
MenuEntryButton<String>(
|
|
childBuilder: (style) => Text(translate(title), style: style),
|
|
proc: () => connect(context, _idController.id,
|
|
loginMethod: 'request-approval',
|
|
isFileTransfer: isFileTransfer,
|
|
isViewCamera: isViewCamera,
|
|
isTerminal: isTerminal,
|
|
isTcpTunneling: isTcpTunneling),
|
|
padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left),
|
|
dismissOnClicked: true,
|
|
);
|
|
|
|
return RequestApprovalMenu(
|
|
title: translate('Request approval'),
|
|
padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left),
|
|
arrowScale: MenuConfig.iconScale,
|
|
entries: [
|
|
action('Connect'),
|
|
action('Transfer file', isFileTransfer: true),
|
|
action('View camera', isViewCamera: true),
|
|
action('${translate('Terminal')} (beta)', isTerminal: true),
|
|
if (isDesktop) action('TCP tunneling', isTcpTunneling: true),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// UI for the remote ID TextField.
|
|
/// Search for a peer.
|
|
Widget _buildRemoteIDTextField(BuildContext context) {
|
|
var w = Container(
|
|
width: 320 + 20 * 2,
|
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 22),
|
|
decoration: BoxDecoration(
|
|
borderRadius: const BorderRadius.all(Radius.circular(13)),
|
|
border: Border.all(color: Theme.of(context).colorScheme.background)),
|
|
child: Ink(
|
|
child: Column(
|
|
children: [
|
|
getConnectionPageTitle(context, false).marginOnly(bottom: 15),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: RawAutocomplete<Peer>(
|
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
|
if (textEditingValue.text == '') {
|
|
_autocompleteOpts = const Iterable<Peer>.empty();
|
|
} else if (_allPeersLoader.peers.isEmpty &&
|
|
!_allPeersLoader.isPeersLoaded) {
|
|
Peer emptyPeer = Peer(
|
|
id: '',
|
|
username: '',
|
|
hostname: '',
|
|
alias: '',
|
|
platform: '',
|
|
tags: [],
|
|
hash: '',
|
|
password: '',
|
|
forceAlwaysRelay: false,
|
|
rdpPort: '',
|
|
rdpUsername: '',
|
|
loginName: '',
|
|
device_group_name: '',
|
|
note: '',
|
|
);
|
|
_autocompleteOpts = [emptyPeer];
|
|
} else {
|
|
String textWithoutSpaces =
|
|
textEditingValue.text.replaceAll(" ", "");
|
|
if (int.tryParse(textWithoutSpaces) != null) {
|
|
textEditingValue = TextEditingValue(
|
|
text: textWithoutSpaces,
|
|
selection: textEditingValue.selection,
|
|
);
|
|
}
|
|
String textToFind = textEditingValue.text.toLowerCase();
|
|
_autocompleteOpts = _allPeersLoader.peers
|
|
.where((peer) =>
|
|
peer.id.toLowerCase().contains(textToFind) ||
|
|
peer.username
|
|
.toLowerCase()
|
|
.contains(textToFind) ||
|
|
peer.hostname
|
|
.toLowerCase()
|
|
.contains(textToFind) ||
|
|
peer.alias.toLowerCase().contains(textToFind))
|
|
.toList();
|
|
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
|
}
|
|
return _autocompleteOpts;
|
|
},
|
|
focusNode: _idFocusNode,
|
|
textEditingController: _idEditingController,
|
|
fieldViewBuilder: (
|
|
BuildContext context,
|
|
TextEditingController fieldTextEditingController,
|
|
FocusNode fieldFocusNode,
|
|
VoidCallback onFieldSubmitted,
|
|
) {
|
|
updateTextAndPreserveSelection(
|
|
fieldTextEditingController, _idController.text);
|
|
return Obx(() => TextField(
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
keyboardType: TextInputType.visiblePassword,
|
|
focusNode: fieldFocusNode,
|
|
style: const TextStyle(
|
|
fontFamily: 'WorkSans',
|
|
fontSize: 22,
|
|
height: 1.4,
|
|
),
|
|
maxLines: 1,
|
|
cursorColor:
|
|
Theme.of(context).textTheme.titleLarge?.color,
|
|
decoration: InputDecoration(
|
|
filled: false,
|
|
counterText: '',
|
|
hintText: _idInputFocused.value
|
|
? null
|
|
: translate('Enter Remote ID'),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 15, vertical: 13)),
|
|
controller: fieldTextEditingController,
|
|
inputFormatters: [IDTextInputFormatter()],
|
|
onChanged: (v) {
|
|
_idController.id = v;
|
|
},
|
|
onSubmitted: (_) {
|
|
onConnect();
|
|
},
|
|
).workaroundFreezeLinuxMint());
|
|
},
|
|
onSelected: (option) {
|
|
setState(() {
|
|
_idController.id = option.id;
|
|
FocusScope.of(context).unfocus();
|
|
});
|
|
},
|
|
optionsViewBuilder: (BuildContext context,
|
|
AutocompleteOnSelected<Peer> onSelected,
|
|
Iterable<Peer> options) {
|
|
options = _autocompleteOpts;
|
|
double maxHeight = options.length * 50;
|
|
if (options.length == 1) {
|
|
maxHeight = 52;
|
|
} else if (options.length == 3) {
|
|
maxHeight = 146;
|
|
} else if (options.length == 4) {
|
|
maxHeight = 193;
|
|
}
|
|
maxHeight = maxHeight.clamp(0, 200);
|
|
|
|
return Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.3),
|
|
blurRadius: 5,
|
|
spreadRadius: 1,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(5),
|
|
child: Material(
|
|
elevation: 4,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: maxHeight,
|
|
maxWidth: 319,
|
|
),
|
|
child: _allPeersLoader.peers.isEmpty &&
|
|
!_allPeersLoader.isPeersLoaded
|
|
? Container(
|
|
height: 80,
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
))
|
|
: Padding(
|
|
padding:
|
|
const EdgeInsets.only(top: 5),
|
|
child: ListView(
|
|
children: options
|
|
.map((peer) =>
|
|
AutocompletePeerTile(
|
|
onSelect: () =>
|
|
onSelected(peer),
|
|
peer: peer))
|
|
.toList(),
|
|
),
|
|
),
|
|
),
|
|
))),
|
|
);
|
|
},
|
|
)),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 13.0),
|
|
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
|
SizedBox(
|
|
height: 28.0,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
onConnect();
|
|
},
|
|
child: Text(translate("Connect")),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
height: 28.0,
|
|
width: 28.0,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Theme.of(context).dividerColor),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Center(
|
|
child: StatefulBuilder(
|
|
builder: (context, setState) {
|
|
var offset = Offset(0, 0);
|
|
return Obx(() => InkWell(
|
|
child: _menuOpen.value
|
|
? Transform.rotate(
|
|
angle: pi,
|
|
child: Icon(IconFont.more, size: 14),
|
|
)
|
|
: Icon(IconFont.more, size: 14),
|
|
onTapDown: (e) {
|
|
offset = e.globalPosition;
|
|
},
|
|
onTap: () async {
|
|
_menuOpen.value = true;
|
|
final x = offset.dx;
|
|
final y = offset.dy;
|
|
await mod_menu
|
|
.showMenu(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(x, y, x, y),
|
|
items: [
|
|
(
|
|
'Transfer file',
|
|
() => onConnect(isFileTransfer: true)
|
|
),
|
|
(
|
|
'View camera',
|
|
() => onConnect(isViewCamera: true)
|
|
),
|
|
(
|
|
'${translate('Terminal')} (beta)',
|
|
() => onConnect(isTerminal: true)
|
|
),
|
|
// `connect` routes this through the
|
|
// desktop path only; the peer card gates
|
|
// it the same way.
|
|
if (isDesktop)
|
|
(
|
|
'TCP tunneling',
|
|
() => onConnect(isTcpTunneling: true)
|
|
),
|
|
]
|
|
.map<MenuEntryBase<String>>(
|
|
(e) => MenuEntryButton<String>(
|
|
childBuilder: (TextStyle? style) =>
|
|
Text(
|
|
translate(e.$1),
|
|
style: style,
|
|
),
|
|
proc: () => e.$2(),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal:
|
|
kDesktopMenuPadding.left),
|
|
dismissOnClicked: true,
|
|
))
|
|
.followedBy([
|
|
MenuEntryDivider<String>(),
|
|
_requestApprovalMenu(context),
|
|
])
|
|
.map((e) => e.build(
|
|
context,
|
|
const MenuConfig(
|
|
commonColor: CustomPopupMenuTheme
|
|
.commonColor,
|
|
height:
|
|
CustomPopupMenuTheme.height,
|
|
dividerHeight:
|
|
CustomPopupMenuTheme
|
|
.dividerHeight)))
|
|
.expand((i) => i)
|
|
.toList(),
|
|
elevation: 8,
|
|
)
|
|
.then((_) {
|
|
_menuOpen.value = false;
|
|
});
|
|
},
|
|
));
|
|
},
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
return Container(
|
|
constraints: const BoxConstraints(maxWidth: 600), child: w);
|
|
}
|
|
}
|