connection_page.dart 19 KB

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