From 708801bdf623f0fc1d6a69aeaa1bc0610f78835d Mon Sep 17 00:00:00 2001 From: Kingtous Date: Sun, 29 May 2022 17:19:50 +0800 Subject: [PATCH] feat: add single/multi window manager wrapper & fix issue causing input twice --- .../lib/common/formatter/id_formatter.dart | 4 + .../lib/desktop/pages/connection_page.dart | 27 +- .../desktop/pages/connection_tab_page.dart | 66 + .../lib/desktop/pages/desktop_home_page.dart | 4 +- flutter/lib/desktop/pages/remote_page.dart | 1364 +++++++++++++++++ .../desktop/screen/desktop_remote_screen.dart | 46 + flutter/lib/main.dart | 63 +- flutter/lib/mobile/pages/remote_page.dart | 250 +-- flutter/lib/models/model.dart | 59 +- flutter/lib/utils/multi_window_manager.dart | 93 ++ flutter/pubspec.lock | 14 + flutter/pubspec.yaml | 2 + 12 files changed, 1817 insertions(+), 175 deletions(-) create mode 100644 flutter/lib/common/formatter/id_formatter.dart create mode 100644 flutter/lib/desktop/pages/connection_tab_page.dart create mode 100644 flutter/lib/desktop/pages/remote_page.dart create mode 100644 flutter/lib/desktop/screen/desktop_remote_screen.dart create mode 100644 flutter/lib/utils/multi_window_manager.dart diff --git a/flutter/lib/common/formatter/id_formatter.dart b/flutter/lib/common/formatter/id_formatter.dart new file mode 100644 index 000000000..29aea84ff --- /dev/null +++ b/flutter/lib/common/formatter/id_formatter.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +/// TODO: Divide every 3 number to display ID +class IdFormController extends TextEditingController {} diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 4fb65c2e3..6f0a8115a 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/mobile/pages/file_manager_page.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'dart:async'; + import '../../common.dart'; -import '../../models/model.dart'; import '../../mobile/pages/home_page.dart'; -import '../../mobile/pages/remote_page.dart'; -import '../../mobile/pages/settings_page.dart'; import '../../mobile/pages/scan_page.dart'; -import '../../models/server_model.dart'; +import '../../mobile/pages/settings_page.dart'; +import '../../models/model.dart'; /// Connection page for connecting to a remote peer. class ConnectionPage extends StatefulWidget implements PageShape { @@ -46,7 +45,6 @@ class _ConnectionPageState extends State { Widget build(BuildContext context) { Provider.of(context); if (_idController.text.isEmpty) _idController.text = FFI.getId(); - FFI.serverModel.startService(); return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -55,7 +53,7 @@ class _ConnectionPageState extends State { children: [ getUpdateUI(), getSearchBarUI(), - Container(height: 12), + SizedBox(height: 12), getPeers(), ]), ); @@ -86,12 +84,15 @@ class _ConnectionPageState extends State { ), ); } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) => RemotePage(id: id), - ), - ); + // single window + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (BuildContext context) => RemotePage(id: id), + // ), + // ); + // multi window + await rustDeskWinManager.new_remote_desktop(id); } FocusScopeNode currentFocus = FocusScope.of(context); if (!currentFocus.hasPrimaryFocus) { diff --git a/flutter/lib/desktop/pages/connection_tab_page.dart b/flutter/lib/desktop/pages/connection_tab_page.dart new file mode 100644 index 000000000..ca53224f1 --- /dev/null +++ b/flutter/lib/desktop/pages/connection_tab_page.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; + +class ConnectionTabPage extends StatefulWidget { + final Map params; + + const ConnectionTabPage({Key? key, required this.params}) : super(key: key); + + @override + State createState() => _ConnectionTabPageState(params); +} + +class _ConnectionTabPageState extends State + with SingleTickerProviderStateMixin { + // refactor List when using multi-tab + // this singleton is only for test + late String connectionId; + late TabController tabController; + + _ConnectionTabPageState(Map params) { + connectionId = params['id'] ?? ""; + } + + @override + void initState() { + super.initState(); + rustDeskWinManager.setMethodHandler((call, fromWindowId) async { + print( + "call ${call.method} with args ${call.arguments} from window ${fromWindowId}"); + // for simplify, just replace connectionId + if (call.method == "new_remote_desktop") { + setState(() { + FFI.close(); + connectionId = jsonDecode(call.arguments)["id"]; + }); + } + }); + tabController = TabController(length: 1, vsync: this); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: tabController, + isScrollable: true, + labelColor: Colors.black87, + physics: NeverScrollableScrollPhysics(), + tabs: [ + Tab( + text: connectionId, + ), + ]), + Expanded( + child: TabBarView(controller: tabController, children: [ + RemotePage(key: ValueKey(connectionId), id: connectionId) + ])) + ], + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index ad27a6d3b..90566e165 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -55,8 +55,8 @@ class _DesktopHomePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildControlPanel(context), - buildRecentSession(context), + // buildControlPanel(context), + // buildRecentSession(context), ConnectionPage() ], ); diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart new file mode 100644 index 000000000..6827bde60 --- /dev/null +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -0,0 +1,1364 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:provider/provider.dart'; +import 'package:wakelock/wakelock.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../common.dart'; +import '../../mobile/widgets/dialog.dart'; +import '../../mobile/widgets/gestures.dart'; +import '../../mobile/widgets/overlay.dart'; +import '../../models/model.dart'; + +final initText = '\1' * 1024; + +class RemotePage extends StatefulWidget { + RemotePage({Key? key, required this.id}) : super(key: key); + + final String id; + + @override + _RemotePageState createState() => _RemotePageState(); +} + +class _RemotePageState extends State with WindowListener { + Timer? _interval; + Timer? _timer; + bool _showBar = !isWebDesktop; + double _bottom = 0; + String _value = ''; + double _scale = 1; + double _mouseScrollIntegral = 0; // mouse scroll speed controller + + var _more = true; + var _fn = false; + final FocusNode _mobileFocusNode = FocusNode(); + final FocusNode _physicalFocusNode = FocusNode(); + var _showEdit = false; // use soft keyboard + var _isPhysicalMouse = false; + + @override + void initState() { + super.initState(); + FFI.connect(widget.id); + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + showLoading(translate('Connecting...')); + _interval = + Timer.periodic(Duration(milliseconds: 30), (timer) => interval()); + }); + if (!Platform.isLinux) { + Wakelock.enable(); + } + _physicalFocusNode.requestFocus(); + FFI.ffiModel.updateEventListener(widget.id); + FFI.listenToMouse(true); + WindowManager.instance.addListener(this); + } + + @override + void dispose() { + print("remote page dispose"); + hideMobileActionsOverlay(); + FFI.listenToMouse(false); + FFI.invokeMethod("enable_soft_keyboard", true); + _mobileFocusNode.dispose(); + _physicalFocusNode.dispose(); + FFI.close(); + _interval?.cancel(); + _timer?.cancel(); + SmartDialog.dismiss(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + if (!Platform.isLinux) { + Wakelock.disable(); + } + WindowManager.instance.removeListener(this); + super.dispose(); + } + + void resetTool() { + FFI.resetModifiers(); + } + + bool isKeyboardShown() { + return _bottom >= 100; + } + + // crash on web before widget initiated. + void intervalUnsafe() { + var v = MediaQuery.of(context).viewInsets.bottom; + if (v != _bottom) { + resetTool(); + setState(() { + _bottom = v; + if (v < 100) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: []); + // [pi.version.isNotEmpty] -> check ready or not,avoid login without soft-keyboard + if (chatWindowOverlayEntry == null && + FFI.ffiModel.pi.version.isNotEmpty) { + FFI.invokeMethod("enable_soft_keyboard", false); + } + } + }); + } + } + + void interval() { + try { + intervalUnsafe(); + } catch (e) {} + } + + // handle mobile virtual keyboard + void handleInput(String newValue) { + var oldValue = _value; + _value = newValue; + if (isIOS) { + var i = newValue.length - 1; + for (; i >= 0 && newValue[i] != '\1'; --i) {} + var j = oldValue.length - 1; + for (; j >= 0 && oldValue[j] != '\1'; --j) {} + if (i < j) j = i; + newValue = newValue.substring(j + 1); + oldValue = oldValue.substring(j + 1); + var common = 0; + for (; + common < oldValue.length && + common < newValue.length && + newValue[common] == oldValue[common]; + ++common); + for (i = 0; i < oldValue.length - common; ++i) { + FFI.inputKey('VK_BACK'); + } + if (newValue.length > common) { + var s = newValue.substring(common); + if (s.length > 1) { + FFI.setByName('input_string', s); + } else { + inputChar(s); + } + } + return; + } + if (oldValue.length > 0 && + newValue.length > 0 && + oldValue[0] == '\1' && + newValue[0] != '\1') { + // clipboard + oldValue = ''; + } + if (newValue.length == oldValue.length) { + // ? + } else if (newValue.length < oldValue.length) { + final char = 'VK_BACK'; + FFI.inputKey(char); + } else { + final content = newValue.substring(oldValue.length); + if (content.length > 1) { + if (oldValue != '' && + content.length == 2 && + (content == '""' || + content == '()' || + content == '[]' || + content == '<>' || + content == "{}" || + content == '”“' || + content == '《》' || + content == '()' || + content == '【】')) { + // can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input + FFI.setByName('input_string', content); + openKeyboard(); + return; + } + FFI.setByName('input_string', content); + } else { + inputChar(content); + } + } + } + + void inputChar(String char) { + if (char == '\n') { + char = 'VK_RETURN'; + } else if (char == ' ') { + char = 'VK_SPACE'; + } + FFI.inputKey(char); + } + + void openKeyboard() { + FFI.invokeMethod("enable_soft_keyboard", true); + // destroy first, so that our _value trick can work + _value = initText; + setState(() => _showEdit = false); + _timer?.cancel(); + _timer = Timer(Duration(milliseconds: 30), () { + // show now, and sleep a while to requestFocus to + // make sure edit ready, so that keyboard wont show/hide/show/hide happen + setState(() => _showEdit = true); + _timer?.cancel(); + _timer = Timer(Duration(milliseconds: 30), () { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + _mobileFocusNode.requestFocus(); + }); + }); + } + + void sendRawKey(RawKeyEvent e, {bool? down, bool? press}) { + // for maximum compatibility + final label = _logicalKeyMap[e.logicalKey.keyId] ?? + _physicalKeyMap[e.physicalKey.usbHidUsage] ?? + e.logicalKey.keyLabel; + FFI.inputKey(label, down: down, press: press ?? false); + } + + @override + Widget build(BuildContext context) { + final pi = Provider.of(context).pi; + final hideKeyboard = isKeyboardShown() && _showEdit; + final showActionButton = !_showBar || hideKeyboard; + final keyboard = FFI.ffiModel.permissions['keyboard'] != false; + + return WillPopScope( + onWillPop: () async { + clientClose(); + return false; + }, + child: getRawPointerAndKeyBody( + keyboard, + Scaffold( + // resizeToAvoidBottomInset: true, + floatingActionButton: !showActionButton + ? null + : FloatingActionButton( + mini: !hideKeyboard, + child: Icon( + hideKeyboard ? Icons.expand_more : Icons.expand_less), + backgroundColor: MyTheme.accent, + onPressed: () { + setState(() { + if (hideKeyboard) { + _showEdit = false; + FFI.invokeMethod("enable_soft_keyboard", false); + _mobileFocusNode.unfocus(); + _physicalFocusNode.requestFocus(); + } else { + _showBar = !_showBar; + } + }); + }), + bottomNavigationBar: _showBar && pi.displays.length > 0 + ? getBottomAppBar(keyboard) + : null, + body: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) { + return Container( + color: Colors.black, + child: isWebDesktop + ? getBodyForDesktopWithListener(keyboard) + : SafeArea( + child: Container( + color: MyTheme.canvasColor, + child: _isPhysicalMouse + ? getBodyForMobile() + : getBodyForMobileWithGesture()))); + }) + ], + ))), + ); + } + + Widget getRawPointerAndKeyBody(bool keyboard, Widget child) { + return Listener( + onPointerHover: (e) { + if (e.kind != ui.PointerDeviceKind.mouse) return; + if (!_isPhysicalMouse) { + setState(() { + _isPhysicalMouse = true; + }); + } + if (_isPhysicalMouse) { + FFI.handleMouse(getEvent(e, 'mousemove')); + } + }, + onPointerDown: (e) { + if (e.kind != ui.PointerDeviceKind.mouse) { + if (_isPhysicalMouse) { + setState(() { + _isPhysicalMouse = false; + }); + } + } + if (_isPhysicalMouse) { + FFI.handleMouse(getEvent(e, 'mousedown')); + } + }, + onPointerUp: (e) { + if (e.kind != ui.PointerDeviceKind.mouse) return; + if (_isPhysicalMouse) { + FFI.handleMouse(getEvent(e, 'mouseup')); + } + }, + onPointerMove: (e) { + if (e.kind != ui.PointerDeviceKind.mouse) return; + if (_isPhysicalMouse) { + FFI.handleMouse(getEvent(e, 'mousemove')); + } + }, + onPointerSignal: (e) { + if (e is PointerScrollEvent) { + var dx = e.scrollDelta.dx; + var dy = e.scrollDelta.dy; + if (dx > 0) + dx = -1; + else if (dx < 0) dx = 1; + if (dy > 0) + dy = -1; + else if (dy < 0) dy = 1; + FFI.setByName( + 'send_mouse', '{"type": "wheel", "x": "$dx", "y": "$dy"}'); + } + }, + child: MouseRegion( + cursor: keyboard ? SystemMouseCursors.none : MouseCursor.defer, + child: FocusScope( + autofocus: true, + child: Focus( + autofocus: true, + canRequestFocus: true, + focusNode: _physicalFocusNode, + onKey: (data, e) { + final key = e.logicalKey; + if (e is RawKeyDownEvent) { + if (e.repeat) { + sendRawKey(e, press: true); + } else { + if (e.isAltPressed && !FFI.alt) { + FFI.alt = true; + } else if (e.isControlPressed && !FFI.ctrl) { + FFI.ctrl = true; + } else if (e.isShiftPressed && !FFI.shift) { + FFI.shift = true; + } else if (e.isMetaPressed && !FFI.command) { + FFI.command = true; + } + sendRawKey(e, down: true); + } + } + // [!_showEdit] workaround for soft-keyboard's control_key like Backspace / Enter + if (!_showEdit && e is RawKeyUpEvent) { + if (key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + FFI.alt = false; + } else if (key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + FFI.ctrl = false; + } else if (key == LogicalKeyboardKey.shiftRight || + key == LogicalKeyboardKey.shiftLeft) { + FFI.shift = false; + } else if (key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + FFI.command = false; + } + sendRawKey(e); + } + return KeyEventResult.handled; + }, + child: child)))); + } + + Widget getBottomAppBar(bool keyboard) { + return BottomAppBar( + elevation: 10, + color: MyTheme.accent, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + IconButton( + color: Colors.white, + icon: Icon(Icons.clear), + onPressed: () { + clientClose(); + }, + ) + ] + + [ + IconButton( + color: Colors.white, + icon: Icon(Icons.tv), + onPressed: () { + setState(() => _showEdit = false); + showOptions(); + }, + ) + ] + + (isWebDesktop + ? [] + : FFI.ffiModel.isPeerAndroid + ? [ + IconButton( + color: Colors.white, + icon: Icon(Icons.build), + onPressed: () { + if (mobileActionsOverlayEntry == null) { + showMobileActionsOverlay(); + } else { + hideMobileActionsOverlay(); + } + }, + ) + ] + : [ + IconButton( + color: Colors.white, + icon: Icon(Icons.keyboard), + onPressed: openKeyboard), + IconButton( + color: Colors.white, + icon: Icon(FFI.ffiModel.touchMode + ? Icons.touch_app + : Icons.mouse), + onPressed: changeTouchMode, + ), + ]) + + (isWeb + ? [] + : [ + IconButton( + color: Colors.white, + icon: Icon(Icons.message), + onPressed: () { + FFI.chatModel + .changeCurrentID(ChatModel.clientModeID); + toggleChatOverlay(); + }, + ) + ]) + + [ + IconButton( + color: Colors.white, + icon: Icon(Icons.more_vert), + onPressed: () { + setState(() => _showEdit = false); + showActions(); + }, + ), + ]), + IconButton( + color: Colors.white, + icon: Icon(Icons.expand_more), + onPressed: () { + setState(() => _showBar = !_showBar); + }), + ], + ), + ); + } + + /// touchMode only: + /// LongPress -> right click + /// OneFingerPan -> start/end -> left down start/end + /// onDoubleTapDown -> move to + /// onLongPressDown => move to + /// + /// mouseMode only: + /// DoubleFiner -> right click + /// HoldDrag -> left drag + + Widget getBodyForMobileWithGesture() { + final touchMode = FFI.ffiModel.touchMode; + return getMixinGestureDetector( + child: getBodyForMobile(), + onTapUp: (d) { + if (touchMode) { + FFI.cursorModel.touch( + d.localPosition.dx, d.localPosition.dy, MouseButtons.left); + } else { + FFI.tap(MouseButtons.left); + } + }, + onDoubleTapDown: (d) { + if (touchMode) { + FFI.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + } + }, + onDoubleTap: () { + FFI.tap(MouseButtons.left); + FFI.tap(MouseButtons.left); + }, + onLongPressDown: (d) { + if (touchMode) { + FFI.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + } + }, + onLongPress: () { + FFI.tap(MouseButtons.right); + }, + onDoubleFinerTap: (d) { + if (!touchMode) { + FFI.tap(MouseButtons.right); + } + }, + onHoldDragStart: (d) { + if (!touchMode) { + FFI.sendMouse('down', MouseButtons.left); + } + }, + onHoldDragUpdate: (d) { + if (!touchMode) { + FFI.cursorModel.updatePan(d.delta.dx, d.delta.dy, touchMode); + } + }, + onHoldDragEnd: (_) { + if (!touchMode) { + FFI.sendMouse('up', MouseButtons.left); + } + }, + onOneFingerPanStart: (d) { + if (touchMode) { + FFI.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + FFI.sendMouse('down', MouseButtons.left); + } + }, + onOneFingerPanUpdate: (d) { + FFI.cursorModel.updatePan(d.delta.dx, d.delta.dy, touchMode); + }, + onOneFingerPanEnd: (d) { + if (touchMode) { + FFI.sendMouse('up', MouseButtons.left); + } + }, + // scale + pan event + onTwoFingerScaleUpdate: (d) { + FFI.canvasModel.updateScale(d.scale / _scale); + _scale = d.scale; + FFI.canvasModel.panX(d.focalPointDelta.dx); + FFI.canvasModel.panY(d.focalPointDelta.dy); + }, + onTwoFingerScaleEnd: (d) { + _scale = 1; + FFI.setByName('peer_option', '{"name": "view-style", "value": ""}'); + }, + onThreeFingerVerticalDragUpdate: FFI.ffiModel.isPeerAndroid + ? null + : (d) { + _mouseScrollIntegral += d.delta.dy / 4; + if (_mouseScrollIntegral > 1) { + FFI.scroll(1); + _mouseScrollIntegral = 0; + } else if (_mouseScrollIntegral < -1) { + FFI.scroll(-1); + _mouseScrollIntegral = 0; + } + }); + } + + Widget getBodyForMobile() { + return Container( + color: MyTheme.canvasColor, + child: Stack(children: [ + ImagePaint(), + CursorPaint(), + getHelpTools(), + SizedBox( + width: 0, + height: 0, + child: !_showEdit + ? Container() + : TextFormField( + textInputAction: TextInputAction.newline, + autocorrect: false, + enableSuggestions: false, + autofocus: true, + focusNode: _mobileFocusNode, + maxLines: null, + initialValue: _value, + // trick way to make backspace work always + keyboardType: TextInputType.multiline, + onChanged: handleInput, + ), + ), + ])); + } + + Widget getBodyForDesktopWithListener(bool keyboard) { + var paints = [ImagePaint()]; + if (keyboard || + FFI.getByName('toggle_option', 'show-remote-cursor') == 'true') { + paints.add(CursorPaint()); + } + return Container( + color: MyTheme.canvasColor, child: Stack(children: paints)); + } + + int lastMouseDownButtons = 0; + + Map getEvent(PointerEvent evt, String type) { + final Map out = {}; + out['type'] = type; + out['x'] = evt.position.dx; + out['y'] = evt.position.dy; + if (FFI.alt) out['alt'] = 'true'; + if (FFI.shift) out['shift'] = 'true'; + if (FFI.ctrl) out['ctrl'] = 'true'; + if (FFI.command) out['command'] = 'true'; + out['buttons'] = evt + .buttons; // left button: 1, right button: 2, middle button: 4, 1 | 2 = 3 (left + right) + if (evt.buttons != 0) { + lastMouseDownButtons = evt.buttons; + } else { + out['buttons'] = lastMouseDownButtons; + } + return out; + } + + void showActions() { + final size = MediaQuery.of(context).size; + final x = 120.0; + final y = size.height; + final more = >[]; + final pi = FFI.ffiModel.pi; + final perms = FFI.ffiModel.permissions; + if (pi.version.isNotEmpty) { + more.add(PopupMenuItem( + child: Text(translate('Refresh')), value: 'refresh')); + } + more.add(PopupMenuItem( + child: Row( + children: ([ + Container(width: 100.0, child: Text(translate('OS Password'))), + TextButton( + style: flatButtonStyle, + onPressed: () { + Navigator.pop(context); + showSetOSPassword(false); + }, + child: Icon(Icons.edit, color: MyTheme.accent), + ) + ])), + value: 'enter_os_password')); + if (!isWebDesktop) { + if (perms['keyboard'] != false && perms['clipboard'] != false) { + more.add(PopupMenuItem( + child: Text(translate('Paste')), value: 'paste')); + } + more.add(PopupMenuItem( + child: Text(translate('Reset canvas')), value: 'reset_canvas')); + } + if (perms['keyboard'] != false) { + if (pi.platform == 'Linux' || pi.sasEnabled) { + more.add(PopupMenuItem( + child: Text(translate('Insert') + ' Ctrl + Alt + Del'), + value: 'cad')); + } + more.add(PopupMenuItem( + child: Text(translate('Insert Lock')), value: 'lock')); + if (pi.platform == 'Windows' && + FFI.getByName('toggle_option', 'privacy-mode') != 'true') { + more.add(PopupMenuItem( + child: Text(translate( + (FFI.ffiModel.inputBlocked ? 'Unb' : 'B') + 'lock user input')), + value: 'block-input')); + } + } + () async { + var value = await showMenu( + context: context, + position: RelativeRect.fromLTRB(x, y, x, y), + items: more, + elevation: 8, + ); + if (value == 'cad') { + FFI.setByName('ctrl_alt_del'); + } else if (value == 'lock') { + FFI.setByName('lock_screen'); + } else if (value == 'block-input') { + FFI.setByName('toggle_option', + (FFI.ffiModel.inputBlocked ? 'un' : '') + 'block-input'); + FFI.ffiModel.inputBlocked = !FFI.ffiModel.inputBlocked; + } else if (value == 'refresh') { + FFI.setByName('refresh'); + } else if (value == 'paste') { + () async { + ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null && data.text != null) { + FFI.setByName('input_string', '${data.text}'); + } + }(); + } else if (value == 'enter_os_password') { + var password = FFI.getByName('peer_option', "os-password"); + if (password != "") { + FFI.setByName('input_os_password', password); + } else { + showSetOSPassword(true); + } + } else if (value == 'reset_canvas') { + FFI.cursorModel.reset(); + } + }(); + } + + void changeTouchMode() { + setState(() => _showEdit = false); + showModalBottomSheet( + backgroundColor: MyTheme.grayBg, + isScrollControlled: true, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(5))), + builder: (context) => DraggableScrollableSheet( + expand: false, + builder: (context, scrollController) { + return SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: 10), + child: GestureHelp( + touchMode: FFI.ffiModel.touchMode, + onTouchModeChange: (t) { + FFI.ffiModel.toggleTouchMode(); + final v = FFI.ffiModel.touchMode ? 'Y' : ''; + FFI.setByName('peer_option', + '{"name": "touch-mode", "value": "$v"}'); + })); + })); + } + + Widget getHelpTools() { + final keyboard = isKeyboardShown(); + if (!keyboard) { + return SizedBox(); + } + final size = MediaQuery.of(context).size; + var wrap = (String text, void Function() onPressed, + [bool? active, IconData? icon]) { + return TextButton( + style: TextButton.styleFrom( + minimumSize: Size(0, 0), + padding: EdgeInsets.symmetric(vertical: 10, horizontal: 9.75), + //adds padding inside the button + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + //limits the touch area to the button area + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5.0), + ), + backgroundColor: active == true ? MyTheme.accent80 : null, + ), + child: icon != null + ? Icon(icon, size: 17, color: Colors.white) + : Text(translate(text), + style: TextStyle(color: Colors.white, fontSize: 11)), + onPressed: onPressed); + }; + final pi = FFI.ffiModel.pi; + final isMac = pi.platform == "Mac OS"; + final modifiers = [ + wrap('Ctrl ', () { + setState(() => FFI.ctrl = !FFI.ctrl); + }, FFI.ctrl), + wrap(' Alt ', () { + setState(() => FFI.alt = !FFI.alt); + }, FFI.alt), + wrap('Shift', () { + setState(() => FFI.shift = !FFI.shift); + }, FFI.shift), + wrap(isMac ? ' Cmd ' : ' Win ', () { + setState(() => FFI.command = !FFI.command); + }, FFI.command), + ]; + final keys = [ + wrap( + ' Fn ', + () => setState( + () { + _fn = !_fn; + if (_fn) { + _more = false; + } + }, + ), + _fn), + wrap( + ' ... ', + () => setState( + () { + _more = !_more; + if (_more) { + _fn = false; + } + }, + ), + _more), + ]; + final fn = [ + SizedBox(width: 9999), + ]; + for (var i = 1; i <= 12; ++i) { + final name = 'F' + i.toString(); + fn.add(wrap(name, () { + FFI.inputKey('VK_' + name); + })); + } + final more = [ + SizedBox(width: 9999), + wrap('Esc', () { + FFI.inputKey('VK_ESCAPE'); + }), + wrap('Tab', () { + FFI.inputKey('VK_TAB'); + }), + wrap('Home', () { + FFI.inputKey('VK_HOME'); + }), + wrap('End', () { + FFI.inputKey('VK_END'); + }), + wrap('Del', () { + FFI.inputKey('VK_DELETE'); + }), + wrap('PgUp', () { + FFI.inputKey('VK_PRIOR'); + }), + wrap('PgDn', () { + FFI.inputKey('VK_NEXT'); + }), + SizedBox(width: 9999), + wrap('', () { + FFI.inputKey('VK_LEFT'); + }, false, Icons.keyboard_arrow_left), + wrap('', () { + FFI.inputKey('VK_UP'); + }, false, Icons.keyboard_arrow_up), + wrap('', () { + FFI.inputKey('VK_DOWN'); + }, false, Icons.keyboard_arrow_down), + wrap('', () { + FFI.inputKey('VK_RIGHT'); + }, false, Icons.keyboard_arrow_right), + wrap(isMac ? 'Cmd+C' : 'Ctrl+C', () { + sendPrompt(isMac, 'VK_C'); + }), + wrap(isMac ? 'Cmd+V' : 'Ctrl+V', () { + sendPrompt(isMac, 'VK_V'); + }), + wrap(isMac ? 'Cmd+S' : 'Ctrl+S', () { + sendPrompt(isMac, 'VK_S'); + }), + ]; + final space = size.width > 320 ? 4.0 : 2.0; + return Container( + color: Color(0xAA000000), + padding: EdgeInsets.only( + top: keyboard ? 24 : 4, left: 0, right: 0, bottom: 8), + child: Wrap( + spacing: space, + runSpacing: space, + children: [SizedBox(width: 9999)] + + (keyboard + ? modifiers + keys + (_fn ? fn : []) + (_more ? more : []) + : modifiers), + )); + } + + @override + void onWindowEvent(String eventName) { + print("window event: $eventName"); + switch (eventName) { + case 'resize': + FFI.canvasModel.updateViewStyle(); + break; + case 'maximize': + Future.delayed(Duration(milliseconds: 100), () { + FFI.canvasModel.updateViewStyle(); + }); + break; + } + } +} + +class ImagePaint extends StatelessWidget { + @override + Widget build(BuildContext context) { + final m = Provider.of(context); + final c = Provider.of(context); + final adjust = FFI.cursorModel.adjustForKeyboard(); + var s = c.scale; + return CustomPaint( + painter: new ImagePainter( + image: m.image, x: c.x / s, y: (c.y - adjust) / s, scale: s), + ); + } +} + +class CursorPaint extends StatelessWidget { + @override + Widget build(BuildContext context) { + final m = Provider.of(context); + final c = Provider.of(context); + final adjust = FFI.cursorModel.adjustForKeyboard(); + var s = c.scale; + return CustomPaint( + painter: new ImagePainter( + image: m.image, + x: m.x * s - m.hotx + c.x, + y: m.y * s - m.hoty + c.y - adjust, + scale: 1), + ); + } +} + +class ImagePainter extends CustomPainter { + ImagePainter({ + required this.image, + required this.x, + required this.y, + required this.scale, + }); + + ui.Image? image; + double x; + double y; + double scale; + + @override + void paint(Canvas canvas, Size size) { + if (image == null) return; + canvas.scale(scale, scale); + canvas.drawImage(image!, new Offset(x, y), new Paint()); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return oldDelegate != this; + } +} + +CheckboxListTile getToggle( + void Function(void Function()) setState, option, name) { + return CheckboxListTile( + value: FFI.getByName('toggle_option', option) == 'true', + onChanged: (v) { + setState(() { + FFI.setByName('toggle_option', option); + }); + }, + dense: true, + title: Text(translate(name))); +} + +RadioListTile getRadio(String name, String toValue, String curValue, + void Function(String?) onChange) { + return RadioListTile( + controlAffinity: ListTileControlAffinity.trailing, + title: Text(translate(name)), + value: toValue, + groupValue: curValue, + onChanged: onChange, + dense: true, + ); +} + +void showOptions() { + String quality = FFI.getByName('image_quality'); + if (quality == '') quality = 'balanced'; + String viewStyle = FFI.getByName('peer_option', 'view-style'); + var displays = []; + final pi = FFI.ffiModel.pi; + final image = FFI.ffiModel.getConnectionImage(); + if (image != null) + displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); + if (pi.displays.length > 1) { + final cur = pi.currentDisplay; + final children = []; + for (var i = 0; i < pi.displays.length; ++i) + children.add(InkWell( + onTap: () { + if (i == cur) return; + FFI.setByName('switch_display', i.toString()); + SmartDialog.dismiss(); + }, + child: Ink( + width: 40, + height: 40, + decoration: BoxDecoration( + border: Border.all(color: Colors.black87), + color: i == cur ? Colors.black87 : Colors.white), + child: Center( + child: Text((i + 1).toString(), + style: TextStyle( + color: i == cur ? Colors.white : Colors.black87)))))); + displays.add(Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: children, + ))); + } + if (displays.isNotEmpty) { + displays.add(Divider(color: MyTheme.border)); + } + final perms = FFI.ffiModel.permissions; + + DialogManager.show((setState, close) { + final more = []; + if (perms['audio'] != false) { + more.add(getToggle(setState, 'disable-audio', 'Mute')); + } + if (perms['keyboard'] != false) { + if (perms['clipboard'] != false) + more.add(getToggle(setState, 'disable-clipboard', 'Disable clipboard')); + more.add(getToggle( + setState, 'lock-after-session-end', 'Lock after session end')); + if (pi.platform == 'Windows') { + more.add(getToggle(setState, 'privacy-mode', 'Privacy mode')); + } + } + var setQuality = (String? value) { + if (value == null) return; + setState(() { + quality = value; + FFI.setByName('image_quality', value); + }); + }; + var setViewStyle = (String? value) { + if (value == null) return; + setState(() { + viewStyle = value; + FFI.setByName( + 'peer_option', '{"name": "view-style", "value": "$value"}'); + FFI.canvasModel.updateViewStyle(); + }); + }; + return CustomAlertDialog( + title: SizedBox.shrink(), + content: Column( + mainAxisSize: MainAxisSize.min, + children: displays + + [ + getRadio('Original', 'original', viewStyle, setViewStyle), + getRadio('Shrink', 'shrink', viewStyle, setViewStyle), + getRadio('Stretch', 'stretch', viewStyle, setViewStyle), + Divider(color: MyTheme.border), + getRadio('Good image quality', 'best', quality, setQuality), + getRadio('Balanced', 'balanced', quality, setQuality), + getRadio('Optimize reaction time', 'low', quality, setQuality), + Divider(color: MyTheme.border), + getToggle(setState, 'show-remote-cursor', 'Show remote cursor'), + ] + + more), + actions: [], + contentPadding: 0, + ); + }, clickMaskDismiss: true, backDismiss: true); +} + +void showSetOSPassword(bool login) { + final controller = TextEditingController(); + var password = FFI.getByName('peer_option', "os-password"); + var autoLogin = FFI.getByName('peer_option', "auto-login") != ""; + controller.text = password; + DialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate('OS Password')), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + PasswordWidget(controller: controller), + CheckboxListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + translate('Auto Login'), + ), + value: autoLogin, + onChanged: (v) { + if (v == null) return; + setState(() => autoLogin = v); + }, + ), + ]), + actions: [ + TextButton( + style: flatButtonStyle, + onPressed: () { + close(); + }, + child: Text(translate('Cancel')), + ), + TextButton( + style: flatButtonStyle, + onPressed: () { + var text = controller.text.trim(); + FFI.setByName( + 'peer_option', '{"name": "os-password", "value": "$text"}'); + FFI.setByName('peer_option', + '{"name": "auto-login", "value": "${autoLogin ? 'Y' : ''}"}'); + if (text != "" && login) { + FFI.setByName('input_os_password', text); + } + close(); + }, + child: Text(translate('OK')), + ), + ]); + }); +} + +void sendPrompt(bool isMac, String key) { + final old = isMac ? FFI.command : FFI.ctrl; + if (isMac) { + FFI.command = true; + } else { + FFI.ctrl = true; + } + FFI.inputKey(key); + if (isMac) { + FFI.command = old; + } else { + FFI.ctrl = old; + } +} + +/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels +/// see [LogicalKeyboardKey.keyLabel] +const Map _logicalKeyMap = { + 0x00000000020: 'VK_SPACE', + 0x00000000022: 'VK_QUOTE', + 0x0000000002c: 'VK_COMMA', + 0x0000000002d: 'VK_MINUS', + 0x0000000002f: 'VK_SLASH', + 0x00000000030: 'VK_0', + 0x00000000031: 'VK_1', + 0x00000000032: 'VK_2', + 0x00000000033: 'VK_3', + 0x00000000034: 'VK_4', + 0x00000000035: 'VK_5', + 0x00000000036: 'VK_6', + 0x00000000037: 'VK_7', + 0x00000000038: 'VK_8', + 0x00000000039: 'VK_9', + 0x0000000003b: 'VK_SEMICOLON', + 0x0000000003d: 'VK_PLUS', // it is = + 0x0000000005b: 'VK_LBRACKET', + 0x0000000005c: 'VK_BACKSLASH', + 0x0000000005d: 'VK_RBRACKET', + 0x00000000061: 'VK_A', + 0x00000000062: 'VK_B', + 0x00000000063: 'VK_C', + 0x00000000064: 'VK_D', + 0x00000000065: 'VK_E', + 0x00000000066: 'VK_F', + 0x00000000067: 'VK_G', + 0x00000000068: 'VK_H', + 0x00000000069: 'VK_I', + 0x0000000006a: 'VK_J', + 0x0000000006b: 'VK_K', + 0x0000000006c: 'VK_L', + 0x0000000006d: 'VK_M', + 0x0000000006e: 'VK_N', + 0x0000000006f: 'VK_O', + 0x00000000070: 'VK_P', + 0x00000000071: 'VK_Q', + 0x00000000072: 'VK_R', + 0x00000000073: 'VK_S', + 0x00000000074: 'VK_T', + 0x00000000075: 'VK_U', + 0x00000000076: 'VK_V', + 0x00000000077: 'VK_W', + 0x00000000078: 'VK_X', + 0x00000000079: 'VK_Y', + 0x0000000007a: 'VK_Z', + 0x00100000008: 'VK_BACK', + 0x00100000009: 'VK_TAB', + 0x0010000000d: 'VK_ENTER', + 0x0010000001b: 'VK_ESCAPE', + 0x0010000007f: 'VK_DELETE', + 0x00100000104: 'VK_CAPITAL', + 0x00100000301: 'VK_DOWN', + 0x00100000302: 'VK_LEFT', + 0x00100000303: 'VK_RIGHT', + 0x00100000304: 'VK_UP', + 0x00100000305: 'VK_END', + 0x00100000306: 'VK_HOME', + 0x00100000307: 'VK_NEXT', + 0x00100000308: 'VK_PRIOR', + 0x00100000401: 'VK_CLEAR', + 0x00100000407: 'VK_INSERT', + 0x00100000504: 'VK_CANCEL', + 0x00100000506: 'VK_EXECUTE', + 0x00100000508: 'VK_HELP', + 0x00100000509: 'VK_PAUSE', + 0x0010000050c: 'VK_SELECT', + 0x00100000608: 'VK_PRINT', + 0x00100000705: 'VK_CONVERT', + 0x00100000706: 'VK_FINAL', + 0x00100000711: 'VK_HANGUL', + 0x00100000712: 'VK_HANJA', + 0x00100000713: 'VK_JUNJA', + 0x00100000718: 'VK_KANA', + 0x00100000719: 'VK_KANJI', + 0x00100000801: 'VK_F1', + 0x00100000802: 'VK_F2', + 0x00100000803: 'VK_F3', + 0x00100000804: 'VK_F4', + 0x00100000805: 'VK_F5', + 0x00100000806: 'VK_F6', + 0x00100000807: 'VK_F7', + 0x00100000808: 'VK_F8', + 0x00100000809: 'VK_F9', + 0x0010000080a: 'VK_F10', + 0x0010000080b: 'VK_F11', + 0x0010000080c: 'VK_F12', + 0x00100000d2b: 'Apps', + 0x00200000002: 'VK_SLEEP', + 0x00200000100: 'VK_CONTROL', + 0x00200000101: 'RControl', + 0x00200000102: 'VK_SHIFT', + 0x00200000103: 'RShift', + 0x00200000104: 'VK_MENU', + 0x00200000105: 'RAlt', + 0x002000001f0: 'VK_CONTROL', + 0x002000001f2: 'VK_SHIFT', + 0x002000001f4: 'VK_MENU', + 0x002000001f6: 'Meta', + 0x0020000022a: 'VK_MULTIPLY', + 0x0020000022b: 'VK_ADD', + 0x0020000022d: 'VK_SUBTRACT', + 0x0020000022e: 'VK_DECIMAL', + 0x0020000022f: 'VK_DIVIDE', + 0x00200000230: 'VK_NUMPAD0', + 0x00200000231: 'VK_NUMPAD1', + 0x00200000232: 'VK_NUMPAD2', + 0x00200000233: 'VK_NUMPAD3', + 0x00200000234: 'VK_NUMPAD4', + 0x00200000235: 'VK_NUMPAD5', + 0x00200000236: 'VK_NUMPAD6', + 0x00200000237: 'VK_NUMPAD7', + 0x00200000238: 'VK_NUMPAD8', + 0x00200000239: 'VK_NUMPAD9', +}; + +/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _debugName +/// see [PhysicalKeyboardKey.debugName] -> _debugName +const Map _physicalKeyMap = { + 0x00010082: 'VK_SLEEP', + 0x00070004: 'VK_A', + 0x00070005: 'VK_B', + 0x00070006: 'VK_C', + 0x00070007: 'VK_D', + 0x00070008: 'VK_E', + 0x00070009: 'VK_F', + 0x0007000a: 'VK_G', + 0x0007000b: 'VK_H', + 0x0007000c: 'VK_I', + 0x0007000d: 'VK_J', + 0x0007000e: 'VK_K', + 0x0007000f: 'VK_L', + 0x00070010: 'VK_M', + 0x00070011: 'VK_N', + 0x00070012: 'VK_O', + 0x00070013: 'VK_P', + 0x00070014: 'VK_Q', + 0x00070015: 'VK_R', + 0x00070016: 'VK_S', + 0x00070017: 'VK_T', + 0x00070018: 'VK_U', + 0x00070019: 'VK_V', + 0x0007001a: 'VK_W', + 0x0007001b: 'VK_X', + 0x0007001c: 'VK_Y', + 0x0007001d: 'VK_Z', + 0x0007001e: 'VK_1', + 0x0007001f: 'VK_2', + 0x00070020: 'VK_3', + 0x00070021: 'VK_4', + 0x00070022: 'VK_5', + 0x00070023: 'VK_6', + 0x00070024: 'VK_7', + 0x00070025: 'VK_8', + 0x00070026: 'VK_9', + 0x00070027: 'VK_0', + 0x00070028: 'VK_ENTER', + 0x00070029: 'VK_ESCAPE', + 0x0007002a: 'VK_BACK', + 0x0007002b: 'VK_TAB', + 0x0007002c: 'VK_SPACE', + 0x0007002d: 'VK_MINUS', + 0x0007002e: 'VK_PLUS', // it is = + 0x0007002f: 'VK_LBRACKET', + 0x00070030: 'VK_RBRACKET', + 0x00070033: 'VK_SEMICOLON', + 0x00070034: 'VK_QUOTE', + 0x00070036: 'VK_COMMA', + 0x00070038: 'VK_SLASH', + 0x00070039: 'VK_CAPITAL', + 0x0007003a: 'VK_F1', + 0x0007003b: 'VK_F2', + 0x0007003c: 'VK_F3', + 0x0007003d: 'VK_F4', + 0x0007003e: 'VK_F5', + 0x0007003f: 'VK_F6', + 0x00070040: 'VK_F7', + 0x00070041: 'VK_F8', + 0x00070042: 'VK_F9', + 0x00070043: 'VK_F10', + 0x00070044: 'VK_F11', + 0x00070045: 'VK_F12', + 0x00070049: 'VK_INSERT', + 0x0007004a: 'VK_HOME', + 0x0007004b: 'VK_PRIOR', // Page Up + 0x0007004c: 'VK_DELETE', + 0x0007004d: 'VK_END', + 0x0007004e: 'VK_NEXT', // Page Down + 0x0007004f: 'VK_RIGHT', + 0x00070050: 'VK_LEFT', + 0x00070051: 'VK_DOWN', + 0x00070052: 'VK_UP', + 0x00070053: 'Num Lock', // TODO rust not impl + 0x00070054: 'VK_DIVIDE', // numpad + 0x00070055: 'VK_MULTIPLY', + 0x00070056: 'VK_SUBTRACT', + 0x00070057: 'VK_ADD', + 0x00070058: 'VK_ENTER', // num enter + 0x00070059: 'VK_NUMPAD0', + 0x0007005a: 'VK_NUMPAD1', + 0x0007005b: 'VK_NUMPAD2', + 0x0007005c: 'VK_NUMPAD3', + 0x0007005d: 'VK_NUMPAD4', + 0x0007005e: 'VK_NUMPAD5', + 0x0007005f: 'VK_NUMPAD6', + 0x00070060: 'VK_NUMPAD7', + 0x00070061: 'VK_NUMPAD8', + 0x00070062: 'VK_NUMPAD9', + 0x00070063: 'VK_DECIMAL', + 0x00070075: 'VK_HELP', + 0x00070077: 'VK_SELECT', + 0x00070088: 'VK_KANA', + 0x0007008a: 'VK_CONVERT', + 0x000700e0: 'VK_CONTROL', + 0x000700e1: 'VK_SHIFT', + 0x000700e2: 'VK_MENU', + 0x000700e3: 'Meta', + 0x000700e4: 'RControl', + 0x000700e5: 'RShift', + 0x000700e6: 'RAlt', + 0x000700e7: 'RWin', + 0x000c00b1: 'VK_PAUSE', + 0x000c00cd: 'VK_PAUSE', + 0x000c019e: 'LOCK_SCREEN', + 0x000c0208: 'VK_PRINT', +}; diff --git a/flutter/lib/desktop/screen/desktop_remote_screen.dart b/flutter/lib/desktop/screen/desktop_remote_screen.dart new file mode 100644 index 000000000..d2a9ab952 --- /dev/null +++ b/flutter/lib/desktop/screen/desktop_remote_screen.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/desktop/pages/connection_tab_page.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:provider/provider.dart'; + +/// multi-tab desktop remote screen +class DesktopRemoteScreen extends StatelessWidget { + final Map params; + + const DesktopRemoteScreen({Key? key, required this.params}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: FFI.ffiModel), + ChangeNotifierProvider.value(value: FFI.imageModel), + ChangeNotifierProvider.value(value: FFI.cursorModel), + ChangeNotifierProvider.value(value: FFI.canvasModel), + ], + child: MaterialApp( + navigatorKey: globalKey, + debugShowCheckedModeBanner: false, + title: 'RustDesk - Remote Desktop', + theme: ThemeData( + primarySwatch: Colors.blue, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: ConnectionTabPage( + params: params, + ), + navigatorObservers: [ + // FirebaseAnalyticsObserver(analytics: analytics), + FlutterSmartDialog.observer + ], + builder: FlutterSmartDialog.init( + builder: isAndroid + ? (_, child) => AccessibilityListener( + child: child, + ) + : null)), + ); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index f69ab6465..2ab1586f0 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,7 +1,12 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; +import 'package:window_manager/window_manager.dart'; import 'common.dart'; import 'mobile/pages/home_page.dart'; @@ -9,7 +14,9 @@ import 'mobile/pages/server_page.dart'; import 'mobile/pages/settings_page.dart'; import 'models/model.dart'; -Future main() async { +int? windowId; + +Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await FFI.ffiModel.init(); // await Firebase.initializeApp(); @@ -17,11 +24,49 @@ Future main() async { toAndroidChannelInit(); } refreshCurrentUser(); - if (isDesktop) { - print("desktop mode: starting service"); - FFI.serverModel.startService(); + runRustDeskApp(args); +} + +void runRustDeskApp(List args) async { + if (!isDesktop) { + runApp(App()); + return; + } + if (args.isNotEmpty && args.first == 'multi_window') { + windowId = int.parse(args[1]); + final argument = args[2].isEmpty + ? Map() + : jsonDecode(args[2]) as Map; + int type = argument['type'] ?? -1; + WindowType wType = type.windowType; + switch (wType) { + case WindowType.RemoteDesktop: + runApp(DesktopRemoteScreen( + params: argument, + )); + break; + default: + break; + } + } else { + // main window + await windowManager.ensureInitialized(); + // start service + FFI.serverModel.startService(); + WindowOptions windowOptions = WindowOptions( + size: Size(1280, 720), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.normal, + ); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + + runApp(App()); } - runApp(App()); } class App extends StatelessWidget { @@ -46,8 +91,8 @@ class App extends StatelessWidget { home: isDesktop ? DesktopHomePage() : !isAndroid - ? WebHomePage() - : HomePage(), + ? WebHomePage() + : HomePage(), navigatorObservers: [ // FirebaseAnalyticsObserver(analytics: analytics), FlutterSmartDialog.observer @@ -55,8 +100,8 @@ class App extends StatelessWidget { builder: FlutterSmartDialog.init( builder: isAndroid ? (_, child) => AccessibilityListener( - child: child, - ) + child: child, + ) : null)), ); } diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 4ba50e8e5..6f10b234d 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1,17 +1,19 @@ +import 'dart:async'; +import 'dart:ui' as ui; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:provider/provider.dart'; -import 'package:flutter/services.dart'; -import 'dart:ui' as ui; -import 'dart:async'; import 'package:wakelock/wakelock.dart'; + import '../../common.dart'; -import '../widgets/gestures.dart'; import '../../models/model.dart'; import '../widgets/dialog.dart'; +import '../widgets/gestures.dart'; import '../widgets/overlay.dart'; final initText = '\1' * 1024; @@ -122,10 +124,10 @@ class _RemotePageState extends State { oldValue = oldValue.substring(j + 1); var common = 0; for (; - common < oldValue.length && - common < newValue.length && - newValue[common] == oldValue[common]; - ++common); + common < oldValue.length && + common < newValue.length && + newValue[common] == oldValue[common]; + ++common); for (i = 0; i < oldValue.length - common; ++i) { FFI.inputKey('VK_BACK'); } @@ -228,26 +230,26 @@ class _RemotePageState extends State { child: getRawPointerAndKeyBody( keyboard, Scaffold( - // resizeToAvoidBottomInset: true, + // resizeToAvoidBottomInset: true, floatingActionButton: !showActionButton ? null : FloatingActionButton( - mini: !hideKeyboard, - child: Icon( - hideKeyboard ? Icons.expand_more : Icons.expand_less), - backgroundColor: MyTheme.accent, - onPressed: () { - setState(() { - if (hideKeyboard) { - _showEdit = false; - FFI.invokeMethod("enable_soft_keyboard", false); - _mobileFocusNode.unfocus(); - _physicalFocusNode.requestFocus(); - } else { - _showBar = !_showBar; - } - }); - }), + mini: !hideKeyboard, + child: Icon( + hideKeyboard ? Icons.expand_more : Icons.expand_less), + backgroundColor: MyTheme.accent, + onPressed: () { + setState(() { + if (hideKeyboard) { + _showEdit = false; + FFI.invokeMethod("enable_soft_keyboard", false); + _mobileFocusNode.unfocus(); + _physicalFocusNode.requestFocus(); + } else { + _showBar = !_showBar; + } + }); + }), bottomNavigationBar: _showBar && pi.displays.length > 0 ? getBottomAppBar(keyboard) : null, @@ -259,11 +261,11 @@ class _RemotePageState extends State { child: isWebDesktop ? getBodyForDesktopWithListener(keyboard) : SafeArea( - child: Container( - color: MyTheme.canvasColor, - child: _isPhysicalMouse - ? getBodyForMobile() - : getBodyForMobileWithGesture()))); + child: Container( + color: MyTheme.canvasColor, + child: _isPhysicalMouse + ? getBodyForMobile() + : getBodyForMobileWithGesture()))); }) ], ))), @@ -379,14 +381,14 @@ class _RemotePageState extends State { children: [ Row( children: [ - IconButton( - color: Colors.white, - icon: Icon(Icons.clear), - onPressed: () { - clientClose(); - }, - ) - ] + + IconButton( + color: Colors.white, + icon: Icon(Icons.clear), + onPressed: () { + clientClose(); + }, + ) + ] + [ IconButton( color: Colors.white, @@ -400,45 +402,45 @@ class _RemotePageState extends State { (isWebDesktop ? [] : FFI.ffiModel.isPeerAndroid - ? [ - IconButton( - color: Colors.white, - icon: Icon(Icons.build), - onPressed: () { - if (mobileActionsOverlayEntry == null) { - showMobileActionsOverlay(); - } else { - hideMobileActionsOverlay(); - } - }, - ) - ] - : [ - IconButton( - color: Colors.white, - icon: Icon(Icons.keyboard), - onPressed: openKeyboard), - IconButton( - color: Colors.white, - icon: Icon(FFI.ffiModel.touchMode - ? Icons.touch_app - : Icons.mouse), - onPressed: changeTouchMode, - ), - ]) + + ? [ + IconButton( + color: Colors.white, + icon: Icon(Icons.build), + onPressed: () { + if (mobileActionsOverlayEntry == null) { + showMobileActionsOverlay(); + } else { + hideMobileActionsOverlay(); + } + }, + ) + ] + : [ + IconButton( + color: Colors.white, + icon: Icon(Icons.keyboard), + onPressed: openKeyboard), + IconButton( + color: Colors.white, + icon: Icon(FFI.ffiModel.touchMode + ? Icons.touch_app + : Icons.mouse), + onPressed: changeTouchMode, + ), + ]) + (isWeb ? [] : [ - IconButton( - color: Colors.white, - icon: Icon(Icons.message), - onPressed: () { - FFI.chatModel - .changeCurrentID(ChatModel.clientModeID); - toggleChatOverlay(); - }, - ) - ]) + + IconButton( + color: Colors.white, + icon: Icon(Icons.message), + onPressed: () { + FFI.chatModel + .changeCurrentID(ChatModel.clientModeID); + toggleChatOverlay(); + }, + ) + ]) + [ IconButton( color: Colors.white, @@ -547,15 +549,15 @@ class _RemotePageState extends State { onThreeFingerVerticalDragUpdate: FFI.ffiModel.isPeerAndroid ? null : (d) { - _mouseScrollIntegral += d.delta.dy / 4; - if (_mouseScrollIntegral > 1) { - FFI.scroll(1); - _mouseScrollIntegral = 0; - } else if (_mouseScrollIntegral < -1) { - FFI.scroll(-1); - _mouseScrollIntegral = 0; - } - }); + _mouseScrollIntegral += d.delta.dy / 4; + if (_mouseScrollIntegral > 1) { + FFI.scroll(1); + _mouseScrollIntegral = 0; + } else if (_mouseScrollIntegral < -1) { + FFI.scroll(-1); + _mouseScrollIntegral = 0; + } + }); } Widget getBodyForMobile() { @@ -571,17 +573,17 @@ class _RemotePageState extends State { child: !_showEdit ? Container() : TextFormField( - textInputAction: TextInputAction.newline, - autocorrect: false, - enableSuggestions: false, - autofocus: true, - focusNode: _mobileFocusNode, - maxLines: null, - initialValue: _value, - // trick way to make backspace work always - keyboardType: TextInputType.multiline, - onChanged: handleInput, - ), + textInputAction: TextInputAction.newline, + autocorrect: false, + enableSuggestions: false, + autofocus: true, + focusNode: _mobileFocusNode, + maxLines: null, + initialValue: _value, + // trick way to make backspace work always + keyboardType: TextInputType.multiline, + onChanged: handleInput, + ), ), ])); } @@ -597,6 +599,7 @@ class _RemotePageState extends State { } int lastMouseDownButtons = 0; + Map getEvent(PointerEvent evt, String type) { final Map out = {}; out['type'] = type; @@ -630,16 +633,16 @@ class _RemotePageState extends State { more.add(PopupMenuItem( child: Row( children: ([ - Container(width: 100.0, child: Text(translate('OS Password'))), - TextButton( - style: flatButtonStyle, - onPressed: () { - Navigator.pop(context); - showSetOSPassword(false); - }, - child: Icon(Icons.edit, color: MyTheme.accent), - ) - ])), + Container(width: 100.0, child: Text(translate('OS Password'))), + TextButton( + style: flatButtonStyle, + onPressed: () { + Navigator.pop(context); + showSetOSPassword(false); + }, + child: Icon(Icons.edit, color: MyTheme.accent), + ) + ])), value: 'enter_os_password')); if (!isWebDesktop) { if (perms['keyboard'] != false && perms['clipboard'] != false) { @@ -665,7 +668,7 @@ class _RemotePageState extends State { value: 'block-input')); } } - () async { + () async { var value = await showMenu( context: context, position: RelativeRect.fromLTRB(x, y, x, y), @@ -683,7 +686,7 @@ class _RemotePageState extends State { } else if (value == 'refresh') { FFI.setByName('refresh'); } else if (value == 'paste') { - () async { + () async { ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null && data.text != null) { FFI.setByName('input_string', '${data.text}'); @@ -749,7 +752,7 @@ class _RemotePageState extends State { child: icon != null ? Icon(icon, size: 17, color: Colors.white) : Text(translate(text), - style: TextStyle(color: Colors.white, fontSize: 11)), + style: TextStyle(color: Colors.white, fontSize: 11)), onPressed: onPressed); }; final pi = FFI.ffiModel.pi; @@ -771,25 +774,25 @@ class _RemotePageState extends State { final keys = [ wrap( ' Fn ', - () => setState( + () => setState( () { - _fn = !_fn; - if (_fn) { - _more = false; - } - }, - ), + _fn = !_fn; + if (_fn) { + _more = false; + } + }, + ), _fn), wrap( ' ... ', - () => setState( + () => setState( () { - _more = !_more; - if (_more) { - _fn = false; - } - }, - ), + _more = !_more; + if (_more) { + _fn = false; + } + }, + ), _more), ]; final fn = [ @@ -920,8 +923,7 @@ class ImagePainter extends CustomPainter { } } -CheckboxListTile getToggle( - void Function(void Function()) setState, option, name) { +CheckboxListTile getToggle(void Function(void Function()) setState, option, name) { return CheckboxListTile( value: FFI.getByName('toggle_option', option) == 'true', onChanged: (v) { diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 464e171aa..d94e69341 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -1,16 +1,18 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'dart:math'; -import 'dart:convert'; -import 'dart:typed_data'; -import 'dart:ui' as ui; -import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; -import 'dart:async'; + import '../common.dart'; import '../mobile/widgets/dialog.dart'; import '../mobile/widgets/overlay.dart'; @@ -596,17 +598,17 @@ class CursorModel with ChangeNotifier { final rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); var pid = FFI.id; ui.decodeImageFromPixels(rgba, width, height, ui.PixelFormat.rgba8888, - (image) { - if (FFI.id != pid) return; - _image = image; - _images[id] = Tuple3(image, _hotx, _hoty); - try { - // my throw exception, because the listener maybe already dispose - notifyListeners(); - } catch (e) { - print('notify cursor: $e'); - } - }); + (image) { + if (FFI.id != pid) return; + _image = image; + _images[id] = Tuple3(image, _hotx, _hoty); + try { + // my throw exception, because the listener maybe already dispose + notifyListeners(); + } catch (e) { + print('notify cursor: $e'); + } + }); } void updateCursorId(Map evt) { @@ -635,8 +637,7 @@ class CursorModel with ChangeNotifier { notifyListeners(); } - void updateDisplayOriginWithCursor( - double x, double y, double xCursor, double yCursor) { + void updateDisplayOriginWithCursor(double x, double y, double xCursor, double yCursor) { _displayOriginX = x; _displayOriginY = y; _x = xCursor; @@ -734,13 +735,17 @@ class FFI { /// [press] indicates a click event(down and up). static void inputKey(String name, {bool? down, bool? press}) { if (!ffiModel.keyboard()) return; - setByName( - 'input_key', - json.encode(modify({ - 'name': name, - 'down': (down ?? false).toString(), - 'press': (press ?? true).toString() - }))); + final Map out = Map(); + out['name'] = name; + // default: down = false + if (down == true) { + out['down'] = "true"; + } + // default: press = true + if (press != false) { + out['press'] = "true"; + } + setByName('input_key', json.encode(modify(out))); } /// Send mouse movement event with distance in [x] and [y]. @@ -760,7 +765,7 @@ class FFI { return peers .map((s) => s as List) .map((s) => - Peer.fromJson(s[0] as String, s[1] as Map)) + Peer.fromJson(s[0] as String, s[1] as Map)) .toList(); } catch (e) { print('peers(): $e'); diff --git a/flutter/lib/utils/multi_window_manager.dart b/flutter/lib/utils/multi_window_manager.dart new file mode 100644 index 000000000..81944e648 --- /dev/null +++ b/flutter/lib/utils/multi_window_manager.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/services.dart'; + +/// must keep the order +enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown } + +extension Index on int { + WindowType get windowType { + switch (this) { + case 0: + return WindowType.Main; + case 1: + return WindowType.RemoteDesktop; + case 2: + return WindowType.FileTransfer; + case 3: + return WindowType.PortForward; + default: + return WindowType.Unknown; + } + } +} + +/// Window Manager +/// mainly use it in `Main Window` +/// use it in sub window is not recommended +class RustDeskMultiWindowManager { + RustDeskMultiWindowManager._(); + + static final instance = RustDeskMultiWindowManager._(); + + int? _remoteDesktopWindowId; + + Future new_remote_desktop(String remote_id) async { + final msg = + jsonEncode({"type": WindowType.RemoteDesktop.index, "id": remote_id}); + + try { + final ids = await DesktopMultiWindow.getAllSubWindowIds(); + if (!ids.contains(_remoteDesktopWindowId)) { + _remoteDesktopWindowId = null; + } + } on Error { + _remoteDesktopWindowId = null; + } + if (_remoteDesktopWindowId == null) { + final remoteDesktopController = + await DesktopMultiWindow.createWindow(msg); + remoteDesktopController + ..setFrame(const Offset(0, 0) & const Size(1280, 720)) + ..center() + ..setTitle("rustdesk - remote desktop") + ..show(); + _remoteDesktopWindowId = remoteDesktopController.windowId; + } else { + return call(WindowType.RemoteDesktop, "new_remote_desktop", msg); + } + } + + Future call(WindowType type, String methodName, dynamic args) async { + int? windowId = findWindowByType(type); + if (windowId == null) { + return; + } + return await DesktopMultiWindow.invokeMethod(windowId, methodName, args); + } + + int? findWindowByType(WindowType type) { + switch (type) { + case WindowType.Main: + break; + case WindowType.RemoteDesktop: + return _remoteDesktopWindowId; + case WindowType.FileTransfer: + break; + case WindowType.PortForward: + break; + case WindowType.Unknown: + break; + } + return null; + } + + void setMethodHandler( + Future Function(MethodCall call, int fromWindowId)? handler) { + DesktopMultiWindow.setMethodHandler(handler); + } +} + +final rustDeskWinManager = RustDeskMultiWindowManager.instance; diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 59f8cdc3e..2f7f30ec9 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -85,6 +85,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.16" + desktop_multi_window: + dependency: "direct main" + description: + name: desktop_multi_window + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.2" device_info: dependency: "direct main" description: @@ -751,6 +758,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.5.2" + window_manager: + dependency: "direct main" + description: + name: window_manager + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.3" xdg_directories: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index f2aa1bf44..75ad90be9 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -55,6 +55,8 @@ dependencies: image: ^3.1.3 flutter_smart_dialog: ^4.3.1 flutter_rust_bridge: ^1.30.0 + window_manager: ^0.2.3 + desktop_multi_window: ^0.0.2 dev_dependencies: flutter_launcher_icons: ^0.9.1