connection_page.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. // main window right pane
  2. import 'dart:async';
  3. import 'dart:convert';
  4. import 'dart:math';
  5. import 'package:flutter/material.dart';
  6. import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
  7. import 'package:flutter_hbb/consts.dart';
  8. import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
  9. import 'package:flutter_hbb/models/state_model.dart';
  10. import 'package:get/get.dart';
  11. import 'package:url_launcher/url_launcher_string.dart';
  12. import 'package:window_manager/window_manager.dart';
  13. import 'package:flutter_hbb/models/peer_model.dart';
  14. import '../../common.dart';
  15. import '../../common/formatter/id_formatter.dart';
  16. import '../../common/widgets/peer_tab_page.dart';
  17. import '../../common/widgets/autocomplete.dart';
  18. import '../../models/platform_model.dart';
  19. import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
  20. class OnlineStatusWidget extends StatefulWidget {
  21. const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
  22. : super(key: key);
  23. final VoidCallback? onSvcStatusChanged;
  24. @override
  25. State<OnlineStatusWidget> createState() => _OnlineStatusWidgetState();
  26. }
  27. /// State for the connection page.
  28. class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
  29. final _svcStopped = Get.find<RxBool>(tag: 'stop-service');
  30. final _svcIsUsingPublicServer = true.obs;
  31. Timer? _updateTimer;
  32. double get em => 14.0;
  33. double? get height => bind.isIncomingOnly() ? null : em * 3;
  34. void onUsePublicServerGuide() {
  35. const url = "https://rustdesk.com/pricing";
  36. canLaunchUrlString(url).then((can) {
  37. if (can) {
  38. launchUrlString(url);
  39. }
  40. });
  41. }
  42. @override
  43. void initState() {
  44. super.initState();
  45. _updateTimer = periodic_immediate(Duration(seconds: 1), () async {
  46. updateStatus();
  47. });
  48. }
  49. @override
  50. void dispose() {
  51. _updateTimer?.cancel();
  52. super.dispose();
  53. }
  54. @override
  55. Widget build(BuildContext context) {
  56. final isIncomingOnly = bind.isIncomingOnly();
  57. startServiceWidget() => Offstage(
  58. offstage: !_svcStopped.value,
  59. child: InkWell(
  60. onTap: () async {
  61. await start_service(true);
  62. },
  63. child: Text(translate("Start service"),
  64. style: TextStyle(
  65. decoration: TextDecoration.underline, fontSize: em)))
  66. .marginOnly(left: em),
  67. );
  68. setupServerWidget() => Flexible(
  69. child: Offstage(
  70. offstage: !(!_svcStopped.value &&
  71. stateGlobal.svcStatus.value == SvcStatus.ready &&
  72. _svcIsUsingPublicServer.value),
  73. child: Row(
  74. crossAxisAlignment: CrossAxisAlignment.center,
  75. children: [
  76. Text(', ', style: TextStyle(fontSize: em)),
  77. Flexible(
  78. child: InkWell(
  79. onTap: onUsePublicServerGuide,
  80. child: Row(
  81. children: [
  82. Flexible(
  83. child: Text(
  84. translate('setup_server_tip'),
  85. style: TextStyle(
  86. decoration: TextDecoration.underline,
  87. fontSize: em),
  88. ),
  89. ),
  90. ],
  91. ),
  92. ),
  93. )
  94. ],
  95. ),
  96. ),
  97. );
  98. basicWidget() => Row(
  99. crossAxisAlignment: CrossAxisAlignment.center,
  100. children: [
  101. Container(
  102. height: 8,
  103. width: 8,
  104. decoration: BoxDecoration(
  105. borderRadius: BorderRadius.circular(4),
  106. color: _svcStopped.value ||
  107. stateGlobal.svcStatus.value == SvcStatus.connecting
  108. ? kColorWarn
  109. : (stateGlobal.svcStatus.value == SvcStatus.ready
  110. ? Color.fromARGB(255, 50, 190, 166)
  111. : Color.fromARGB(255, 224, 79, 95)),
  112. ),
  113. ).marginSymmetric(horizontal: em),
  114. Container(
  115. width: isIncomingOnly ? 226 : null,
  116. child: _buildConnStatusMsg(),
  117. ),
  118. // stop
  119. if (!isIncomingOnly) startServiceWidget(),
  120. // ready && public
  121. // No need to show the guide if is custom client.
  122. if (!isIncomingOnly) setupServerWidget(),
  123. ],
  124. );
  125. return Container(
  126. height: height,
  127. child: Obx(() => isIncomingOnly
  128. ? Column(
  129. children: [
  130. basicWidget(),
  131. Align(
  132. child: startServiceWidget(),
  133. alignment: Alignment.centerLeft)
  134. .marginOnly(top: 2.0, left: 22.0),
  135. ],
  136. )
  137. : basicWidget()),
  138. ).paddingOnly(right: isIncomingOnly ? 8 : 0);
  139. }
  140. _buildConnStatusMsg() {
  141. widget.onSvcStatusChanged?.call();
  142. return Text(
  143. _svcStopped.value
  144. ? translate("Service is not running")
  145. : stateGlobal.svcStatus.value == SvcStatus.connecting
  146. ? translate("connecting_status")
  147. : stateGlobal.svcStatus.value == SvcStatus.notReady
  148. ? translate("not_ready_status")
  149. : translate('Ready'),
  150. style: TextStyle(fontSize: em),
  151. );
  152. }
  153. updateStatus() async {
  154. final status =
  155. jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
  156. final statusNum = status['status_num'] as int;
  157. if (statusNum == 0) {
  158. stateGlobal.svcStatus.value = SvcStatus.connecting;
  159. } else if (statusNum == -1) {
  160. stateGlobal.svcStatus.value = SvcStatus.notReady;
  161. } else if (statusNum == 1) {
  162. stateGlobal.svcStatus.value = SvcStatus.ready;
  163. } else {
  164. stateGlobal.svcStatus.value = SvcStatus.notReady;
  165. }
  166. _svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer();
  167. try {
  168. stateGlobal.videoConnCount.value = status['video_conn_count'] as int;
  169. } catch (_) {}
  170. }
  171. }
  172. /// Connection page for connecting to a remote peer.
  173. class ConnectionPage extends StatefulWidget {
  174. const ConnectionPage({Key? key}) : super(key: key);
  175. @override
  176. State<ConnectionPage> createState() => _ConnectionPageState();
  177. }
  178. /// State for the connection page.
  179. class _ConnectionPageState extends State<ConnectionPage>
  180. with SingleTickerProviderStateMixin, WindowListener {
  181. /// Controller for the id input bar.
  182. final _idController = IDTextEditingController();
  183. final RxBool _idInputFocused = false.obs;
  184. final FocusNode _idFocusNode = FocusNode();
  185. final TextEditingController _idEditingController = TextEditingController();
  186. String selectedConnectionType = 'Connect';
  187. bool isWindowMinimized = false;
  188. final AllPeersLoader _allPeersLoader = AllPeersLoader();
  189. // https://github.com/flutter/flutter/issues/157244
  190. Iterable<Peer> _autocompleteOpts = [];
  191. final _menuOpen = false.obs;
  192. @override
  193. void initState() {
  194. super.initState();
  195. _allPeersLoader.init(setState);
  196. _idFocusNode.addListener(onFocusChanged);
  197. if (_idController.text.isEmpty) {
  198. WidgetsBinding.instance.addPostFrameCallback((_) async {
  199. final lastRemoteId = await bind.mainGetLastRemoteId();
  200. if (lastRemoteId != _idController.id) {
  201. setState(() {
  202. _idController.id = lastRemoteId;
  203. });
  204. }
  205. });
  206. }
  207. Get.put<TextEditingController>(_idEditingController);
  208. Get.put<IDTextEditingController>(_idController);
  209. windowManager.addListener(this);
  210. }
  211. @override
  212. void dispose() {
  213. _idController.dispose();
  214. windowManager.removeListener(this);
  215. _allPeersLoader.clear();
  216. _idFocusNode.removeListener(onFocusChanged);
  217. _idFocusNode.dispose();
  218. _idEditingController.dispose();
  219. if (Get.isRegistered<IDTextEditingController>()) {
  220. Get.delete<IDTextEditingController>();
  221. }
  222. if (Get.isRegistered<TextEditingController>()) {
  223. Get.delete<TextEditingController>();
  224. }
  225. super.dispose();
  226. }
  227. @override
  228. void onWindowEvent(String eventName) {
  229. super.onWindowEvent(eventName);
  230. if (eventName == 'minimize') {
  231. isWindowMinimized = true;
  232. } else if (eventName == 'maximize' || eventName == 'restore') {
  233. if (isWindowMinimized && isWindows) {
  234. // windows can't update when minimized.
  235. Get.forceAppUpdate();
  236. }
  237. isWindowMinimized = false;
  238. }
  239. }
  240. @override
  241. void onWindowEnterFullScreen() {
  242. // Remove edge border by setting the value to zero.
  243. stateGlobal.resizeEdgeSize.value = 0;
  244. }
  245. @override
  246. void onWindowLeaveFullScreen() {
  247. // Restore edge border to default edge size.
  248. stateGlobal.resizeEdgeSize.value = stateGlobal.isMaximized.isTrue
  249. ? kMaximizeEdgeSize
  250. : windowResizeEdgeSize;
  251. }
  252. @override
  253. void onWindowClose() {
  254. super.onWindowClose();
  255. bind.mainOnMainWindowClose();
  256. }
  257. void onFocusChanged() {
  258. _idInputFocused.value = _idFocusNode.hasFocus;
  259. if (_idFocusNode.hasFocus) {
  260. if (_allPeersLoader.needLoad) {
  261. _allPeersLoader.getAllPeers();
  262. }
  263. final textLength = _idEditingController.value.text.length;
  264. // Select all to facilitate removing text, just following the behavior of address input of chrome.
  265. _idEditingController.selection =
  266. TextSelection(baseOffset: 0, extentOffset: textLength);
  267. }
  268. }
  269. @override
  270. Widget build(BuildContext context) {
  271. final isOutgoingOnly = bind.isOutgoingOnly();
  272. return Column(
  273. children: [
  274. Expanded(
  275. child: Column(
  276. children: [
  277. Row(
  278. children: [
  279. Flexible(child: _buildRemoteIDTextField(context)),
  280. ],
  281. ).marginOnly(top: 22),
  282. SizedBox(height: 12),
  283. Divider().paddingOnly(right: 12),
  284. Expanded(child: PeerTabPage()),
  285. ],
  286. ).paddingOnly(left: 12.0)),
  287. if (!isOutgoingOnly) const Divider(height: 1),
  288. if (!isOutgoingOnly) OnlineStatusWidget()
  289. ],
  290. );
  291. }
  292. /// Callback for the connect button.
  293. /// Connects to the selected peer.
  294. void onConnect(
  295. {bool isFileTransfer = false,
  296. bool isViewCamera = false,
  297. bool isTerminal = false}) {
  298. var id = _idController.id;
  299. connect(context, id,
  300. isFileTransfer: isFileTransfer,
  301. isViewCamera: isViewCamera,
  302. isTerminal: isTerminal);
  303. }
  304. /// UI for the remote ID TextField.
  305. /// Search for a peer.
  306. Widget _buildRemoteIDTextField(BuildContext context) {
  307. var w = Container(
  308. width: 320 + 20 * 2,
  309. padding: const EdgeInsets.fromLTRB(20, 24, 20, 22),
  310. decoration: BoxDecoration(
  311. borderRadius: const BorderRadius.all(Radius.circular(13)),
  312. border: Border.all(color: Theme.of(context).colorScheme.background)),
  313. child: Ink(
  314. child: Column(
  315. children: [
  316. getConnectionPageTitle(context, false).marginOnly(bottom: 15),
  317. Row(
  318. children: [
  319. Expanded(
  320. child: RawAutocomplete<Peer>(
  321. optionsBuilder: (TextEditingValue textEditingValue) {
  322. if (textEditingValue.text == '') {
  323. _autocompleteOpts = const Iterable<Peer>.empty();
  324. } else if (_allPeersLoader.peers.isEmpty &&
  325. !_allPeersLoader.isPeersLoaded) {
  326. Peer emptyPeer = Peer(
  327. id: '',
  328. username: '',
  329. hostname: '',
  330. alias: '',
  331. platform: '',
  332. tags: [],
  333. hash: '',
  334. password: '',
  335. forceAlwaysRelay: false,
  336. rdpPort: '',
  337. rdpUsername: '',
  338. loginName: '',
  339. device_group_name: '',
  340. );
  341. _autocompleteOpts = [emptyPeer];
  342. } else {
  343. String textWithoutSpaces =
  344. textEditingValue.text.replaceAll(" ", "");
  345. if (int.tryParse(textWithoutSpaces) != null) {
  346. textEditingValue = TextEditingValue(
  347. text: textWithoutSpaces,
  348. selection: textEditingValue.selection,
  349. );
  350. }
  351. String textToFind = textEditingValue.text.toLowerCase();
  352. _autocompleteOpts = _allPeersLoader.peers
  353. .where((peer) =>
  354. peer.id.toLowerCase().contains(textToFind) ||
  355. peer.username
  356. .toLowerCase()
  357. .contains(textToFind) ||
  358. peer.hostname
  359. .toLowerCase()
  360. .contains(textToFind) ||
  361. peer.alias.toLowerCase().contains(textToFind))
  362. .toList();
  363. }
  364. return _autocompleteOpts;
  365. },
  366. focusNode: _idFocusNode,
  367. textEditingController: _idEditingController,
  368. fieldViewBuilder: (
  369. BuildContext context,
  370. TextEditingController fieldTextEditingController,
  371. FocusNode fieldFocusNode,
  372. VoidCallback onFieldSubmitted,
  373. ) {
  374. updateTextAndPreserveSelection(
  375. fieldTextEditingController, _idController.text);
  376. return Obx(() => TextField(
  377. autocorrect: false,
  378. enableSuggestions: false,
  379. keyboardType: TextInputType.visiblePassword,
  380. focusNode: fieldFocusNode,
  381. style: const TextStyle(
  382. fontFamily: 'WorkSans',
  383. fontSize: 22,
  384. height: 1.4,
  385. ),
  386. maxLines: 1,
  387. cursorColor:
  388. Theme.of(context).textTheme.titleLarge?.color,
  389. decoration: InputDecoration(
  390. filled: false,
  391. counterText: '',
  392. hintText: _idInputFocused.value
  393. ? null
  394. : translate('Enter Remote ID'),
  395. contentPadding: const EdgeInsets.symmetric(
  396. horizontal: 15, vertical: 13)),
  397. controller: fieldTextEditingController,
  398. inputFormatters: [IDTextInputFormatter()],
  399. onChanged: (v) {
  400. _idController.id = v;
  401. },
  402. onSubmitted: (_) {
  403. onConnect();
  404. },
  405. ).workaroundFreezeLinuxMint());
  406. },
  407. onSelected: (option) {
  408. setState(() {
  409. _idController.id = option.id;
  410. FocusScope.of(context).unfocus();
  411. });
  412. },
  413. optionsViewBuilder: (BuildContext context,
  414. AutocompleteOnSelected<Peer> onSelected,
  415. Iterable<Peer> options) {
  416. options = _autocompleteOpts;
  417. double maxHeight = options.length * 50;
  418. if (options.length == 1) {
  419. maxHeight = 52;
  420. } else if (options.length == 3) {
  421. maxHeight = 146;
  422. } else if (options.length == 4) {
  423. maxHeight = 193;
  424. }
  425. maxHeight = maxHeight.clamp(0, 200);
  426. return Align(
  427. alignment: Alignment.topLeft,
  428. child: Container(
  429. decoration: BoxDecoration(
  430. boxShadow: [
  431. BoxShadow(
  432. color: Colors.black.withOpacity(0.3),
  433. blurRadius: 5,
  434. spreadRadius: 1,
  435. ),
  436. ],
  437. ),
  438. child: ClipRRect(
  439. borderRadius: BorderRadius.circular(5),
  440. child: Material(
  441. elevation: 4,
  442. child: ConstrainedBox(
  443. constraints: BoxConstraints(
  444. maxHeight: maxHeight,
  445. maxWidth: 319,
  446. ),
  447. child: _allPeersLoader.peers.isEmpty &&
  448. !_allPeersLoader.isPeersLoaded
  449. ? Container(
  450. height: 80,
  451. child: Center(
  452. child: CircularProgressIndicator(
  453. strokeWidth: 2,
  454. ),
  455. ))
  456. : Padding(
  457. padding:
  458. const EdgeInsets.only(top: 5),
  459. child: ListView(
  460. children: options
  461. .map((peer) =>
  462. AutocompletePeerTile(
  463. onSelect: () =>
  464. onSelected(peer),
  465. peer: peer))
  466. .toList(),
  467. ),
  468. ),
  469. ),
  470. ))),
  471. );
  472. },
  473. )),
  474. ],
  475. ),
  476. Padding(
  477. padding: const EdgeInsets.only(top: 13.0),
  478. child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
  479. SizedBox(
  480. height: 28.0,
  481. child: ElevatedButton(
  482. onPressed: () {
  483. onConnect();
  484. },
  485. child: Text(translate("Connect")),
  486. ),
  487. ),
  488. const SizedBox(width: 8),
  489. Container(
  490. height: 28.0,
  491. width: 28.0,
  492. decoration: BoxDecoration(
  493. border: Border.all(color: Theme.of(context).dividerColor),
  494. borderRadius: BorderRadius.circular(8),
  495. ),
  496. child: Center(
  497. child: StatefulBuilder(
  498. builder: (context, setState) {
  499. var offset = Offset(0, 0);
  500. return Obx(() => InkWell(
  501. child: _menuOpen.value
  502. ? Transform.rotate(
  503. angle: pi,
  504. child: Icon(IconFont.more, size: 14),
  505. )
  506. : Icon(IconFont.more, size: 14),
  507. onTapDown: (e) {
  508. offset = e.globalPosition;
  509. },
  510. onTap: () async {
  511. _menuOpen.value = true;
  512. final x = offset.dx;
  513. final y = offset.dy;
  514. await mod_menu
  515. .showMenu(
  516. context: context,
  517. position: RelativeRect.fromLTRB(x, y, x, y),
  518. items: [
  519. (
  520. 'Transfer file',
  521. () => onConnect(isFileTransfer: true)
  522. ),
  523. (
  524. 'View camera',
  525. () => onConnect(isViewCamera: true)
  526. ),
  527. (
  528. 'Terminal',
  529. () => onConnect(isTerminal: true)
  530. ),
  531. ]
  532. .map((e) => MenuEntryButton<String>(
  533. childBuilder: (TextStyle? style) => Text(
  534. translate(e.$1),
  535. style: style,
  536. ),
  537. proc: () => e.$2(),
  538. padding: EdgeInsets.symmetric(
  539. horizontal: kDesktopMenuPadding.left),
  540. dismissOnClicked: true,
  541. ))
  542. .map((e) => e.build(
  543. context,
  544. const MenuConfig(
  545. commonColor:
  546. CustomPopupMenuTheme.commonColor,
  547. height: CustomPopupMenuTheme.height,
  548. dividerHeight: CustomPopupMenuTheme
  549. .dividerHeight)))
  550. .expand((i) => i)
  551. .toList(),
  552. elevation: 8,
  553. )
  554. .then((_) {
  555. _menuOpen.value = false;
  556. });
  557. },
  558. ));
  559. },
  560. ),
  561. ),
  562. ),
  563. ]),
  564. ),
  565. ],
  566. ),
  567. ),
  568. );
  569. return Container(
  570. constraints: const BoxConstraints(maxWidth: 600), child: w);
  571. }
  572. }