import 'dart:async'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_improved_scrolling/flutter_improved_scrolling.dart'; import 'package:flutter_hbb/models/state_model.dart'; import '../../consts.dart'; import '../../common/widgets/overlay.dart'; import '../../common/widgets/remote_input.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/tabbar_widget.dart'; import 'package:flutter_hbb/native/custom_cursor.dart' if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; final SimpleWrapper _firstEnterImage = SimpleWrapper(false); // Used to skip session close if "move to new window" is clicked. final Map closeSessionOnDispose = {}; class RemotePage extends StatefulWidget { RemotePage({ Key? key, required this.id, required this.toolbarState, this.sessionId, this.tabWindowId, this.password, this.display, this.displays, this.tabController, this.switchUuid, this.forceRelay, this.isSharedPassword, }) : super(key: key); final String id; final SessionID? sessionId; final int? tabWindowId; final int? display; final List? displays; final String? password; final ToolbarState toolbarState; final String? switchUuid; final bool? forceRelay; final bool? isSharedPassword; final SimpleWrapper?> _lastState = SimpleWrapper(null); final DesktopTabController? tabController; FFI get ffi => (_lastState.value! as _RemotePageState)._ffi; @override State createState() { final state = _RemotePageState(); _lastState.value = state; return state; } } class _RemotePageState extends State with AutomaticKeepAliveClientMixin, MultiWindowListener { Timer? _timer; String keyboardMode = "legacy"; bool _isWindowBlur = false; final _cursorOverImage = false.obs; late RxBool _showRemoteCursor; late RxBool _zoomCursor; late RxBool _remoteCursorMoved; late RxBool _keyboardEnabled; var _blockableOverlayState = BlockableOverlayState(); final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode"); Function(bool)? _onEnterOrLeaveImage4Toolbar; late FFI _ffi; SessionID get sessionId => _ffi.sessionId; void _initStates(String id) { initSharedStates(id); _zoomCursor = PeerBoolOption.find(id, kOptionZoomCursor); _showRemoteCursor = ShowRemoteCursorState.find(id); _keyboardEnabled = KeyboardEnabledState.find(id); _remoteCursorMoved = RemoteCursorMovedState.find(id); } @override void initState() { super.initState(); _initStates(widget.id); _ffi = FFI(widget.sessionId); Get.put(_ffi, tag: widget.id); _ffi.imageModel.addCallbackOnFirstImage((String peerId) { showKBLayoutTypeChooserIfNeeded( _ffi.ffiModel.pi.platform, _ffi.dialogManager); }); _ffi.start( widget.id, password: widget.password, isSharedPassword: widget.isSharedPassword, switchUuid: widget.switchUuid, forceRelay: widget.forceRelay, tabWindowId: widget.tabWindowId, display: widget.display, displays: widget.displays, ); WidgetsBinding.instance.addPostFrameCallback((_) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); _ffi.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); if (!isLinux) { WakelockPlus.enable(); } _ffi.ffiModel.updateEventListener(sessionId, widget.id); if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); // Session option should be set after models.dart/FFI.start _showRemoteCursor.value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: 'show-remote-cursor'); _zoomCursor.value = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: kOptionZoomCursor); DesktopMultiWindow.addListener(this); // if (!_isCustomCursorInited) { // customCursorController.registerNeedUpdateCursorCallback( // (String? lastKey, String? currentKey) async { // if (_firstEnterImage.value) { // _firstEnterImage.value = false; // return true; // } // return lastKey == null || lastKey != currentKey; // }); // _isCustomCursorInited = true; // } _blockableOverlayState.applyFfi(_ffi); widget.tabController?.onSelected?.call(widget.id); } @override void onWindowBlur() { super.onWindowBlur(); // On windows, we use `focus` way to handle keyboard better. // Now on Linux, there's some rdev issues which will break the input. // We disable the `focus` way for non-Windows temporarily. if (isWindows) { _isWindowBlur = true; // unfocus the primary-focus when the whole window is lost focus, // and let OS to handle events instead. _rawKeyFocusNode.unfocus(); } stateGlobal.isFocused.value = false; } @override void onWindowFocus() { super.onWindowFocus(); // See [onWindowBlur]. if (isWindows) { _isWindowBlur = false; } stateGlobal.isFocused.value = true; } @override void onWindowRestore() { super.onWindowRestore(); // On windows, we use `onWindowRestore` way to handle window restore from // a minimized state. if (isWindows) { _isWindowBlur = false; } if (!isLinux) { WakelockPlus.enable(); } } // When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not. @override void onWindowMaximize() { super.onWindowMaximize(); if (!isLinux) { WakelockPlus.enable(); } } @override void onWindowMinimize() { super.onWindowMinimize(); if (!isLinux) { WakelockPlus.disable(); } } @override void onWindowEnterFullScreen() { super.onWindowEnterFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(true); } } @override void onWindowLeaveFullScreen() { super.onWindowLeaveFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(false); } } @override Future dispose() async { final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; // https://github.com/flutter/flutter/issues/64935 super.dispose(); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); _ffi.textureModel.onRemotePageDispose(closeSession); // ensure we leave this session, this is a double check _ffi.inputModel.enterOrLeave(false); DesktopMultiWindow.removeListener(this); _ffi.dialogManager.hideMobileActionsOverlay(); _ffi.imageModel.disposeImage(); _ffi.cursorModel.disposeImages(); _ffi.recordingModel.onClose(); _rawKeyFocusNode.dispose(); await _ffi.close(closeSession: closeSession); _timer?.cancel(); _ffi.dialogManager.dismissAll(); if (closeSession) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); } if (!isLinux) { await WakelockPlus.disable(); } await Get.delete(tag: widget.id); removeSharedStates(widget.id); } Widget emptyOverlay() => BlockableOverlay( /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay /// see override build() in [BlockableOverlay] state: _blockableOverlayState, underlying: Container( color: Colors.transparent, ), ); Widget buildBody(BuildContext context) { remoteToolbar(BuildContext context) => RemoteToolbar( id: widget.id, ffi: _ffi, state: widget.toolbarState, onEnterOrLeaveImageSetter: (func) => _onEnterOrLeaveImage4Toolbar = func, onEnterOrLeaveImageCleaner: () => _onEnterOrLeaveImage4Toolbar = null, setRemoteState: setState, ); bodyWidget() { return Stack( children: [ Container( color: kColorCanvas, child: RawKeyFocusScope( focusNode: _rawKeyFocusNode, onFocusChange: (bool imageFocused) { debugPrint( "onFocusChange(window active:${!_isWindowBlur}) $imageFocused"); // See [onWindowBlur]. if (isWindows) { if (_isWindowBlur) { imageFocused = false; Future.delayed(Duration.zero, () { _rawKeyFocusNode.unfocus(); }); } if (imageFocused) { _ffi.inputModel.enterOrLeave(true); } else { _ffi.inputModel.enterOrLeave(false); } } }, inputModel: _ffi.inputModel, child: getBodyForDesktop(context))), Stack( children: [ _ffi.ffiModel.pi.isSet.isTrue && _ffi.ffiModel.waitForFirstImage.isTrue ? emptyOverlay() : () { if (!_ffi.ffiModel.isPeerAndroid) { return Offstage(); } else { if (_ffi.connType == ConnType.defaultConn && _ffi.ffiModel.permissions['keyboard'] != false) { Timer( Duration(milliseconds: 10), () => _ffi.dialogManager .mobileActionsOverlayVisible.value = true); } return Obx(() => Offstage( offstage: _ffi.dialogManager .mobileActionsOverlayVisible.isFalse, child: Overlay(initialEntries: [ makeMobileActionsOverlayEntry( () => _ffi .dialogManager .mobileActionsOverlayVisible .value = false, ffi: _ffi, ) ]), )); } }(), // Use Overlay to enable rebuild every time on menu button click. _ffi.ffiModel.pi.isSet.isTrue ? Overlay( initialEntries: [OverlayEntry(builder: remoteToolbar)]) : remoteToolbar(context), _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), ], ), ], ); } return Scaffold( backgroundColor: Theme.of(context).colorScheme.background, body: Obx(() { final imageReady = _ffi.ffiModel.pi.isSet.isTrue && _ffi.ffiModel.waitForFirstImage.isFalse; if (imageReady) { // If the privacy mode(disable physical displays) is switched, // we should not dismiss the dialog immediately. if (DateTime.now().difference(togglePrivacyModeTime) > const Duration(milliseconds: 3000)) { // `dismissAll()` is to ensure that the state is clean. // It's ok to call dismissAll() here. _ffi.dialogManager.dismissAll(); // Recreate the block state to refresh the state. _blockableOverlayState = BlockableOverlayState(); _blockableOverlayState.applyFfi(_ffi); } // Block the whole `bodyWidget()` when dialog shows. return BlockableOverlay( underlying: bodyWidget(), state: _blockableOverlayState, ); } else { // `_blockableOverlayState` is not recreated here. // The toolbar's block state won't work properly when reconnecting, but that's okay. return bodyWidget(); } }), ); } @override Widget build(BuildContext context) { super.build(context); return WillPopScope( onWillPop: () async { clientClose(sessionId, _ffi.dialogManager); return false; }, child: MultiProvider(providers: [ ChangeNotifierProvider.value(value: _ffi.ffiModel), ChangeNotifierProvider.value(value: _ffi.imageModel), ChangeNotifierProvider.value(value: _ffi.cursorModel), ChangeNotifierProvider.value(value: _ffi.canvasModel), ChangeNotifierProvider.value(value: _ffi.recordingModel), ], child: buildBody(context))); } void enterView(PointerEnterEvent evt) { _cursorOverImage.value = true; _firstEnterImage.value = true; if (_onEnterOrLeaveImage4Toolbar != null) { try { _onEnterOrLeaveImage4Toolbar!(true); } catch (e) { // } } // See [onWindowBlur]. if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } _ffi.inputModel.enterOrLeave(true); } } void leaveView(PointerExitEvent evt) { if (_ffi.ffiModel.keyboard) { _ffi.inputModel.tryMoveEdgeOnExit(evt.position); } _cursorOverImage.value = false; _firstEnterImage.value = false; if (_onEnterOrLeaveImage4Toolbar != null) { try { _onEnterOrLeaveImage4Toolbar!(false); } catch (e) { // } } // See [onWindowBlur]. if (!isWindows) { _ffi.inputModel.enterOrLeave(false); } } Widget _buildRawTouchAndPointerRegion( Widget child, PointerEnterEventListener? onEnter, PointerExitEventListener? onExit, ) { return RawTouchGestureDetectorRegion( child: _buildRawPointerMouseRegion(child, onEnter, onExit), ffi: _ffi, ); } Widget _buildRawPointerMouseRegion( Widget child, PointerEnterEventListener? onEnter, PointerExitEventListener? onExit, ) { return RawPointerMouseRegion( onEnter: onEnter, onExit: onExit, onPointerDown: (event) { // A double check for blur status. // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. // Sometimes the system does not send the necessary focus event to flutter. We should manually // handle this inconsistent status by setting `_isWindowBlur` to false. So we can // ensure the grab-key thread is running when our users are clicking the remote canvas. if (_isWindowBlur) { debugPrint( "Unexpected status: onPointerDown is triggered while the remote window is in blur status"); _isWindowBlur = false; } if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } }, inputModel: _ffi.inputModel, child: child, ); } Widget getBodyForDesktop(BuildContext context) { var paints = [ MouseRegion(onEnter: (evt) { if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); }, onExit: (evt) { if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); }, child: LayoutBuilder(builder: (context, constraints) { final c = Provider.of(context, listen: false); Future.delayed(Duration.zero, () => c.updateViewStyle()); final peerDisplay = CurrentDisplayState.find(widget.id); return Obx( () => _ffi.ffiModel.pi.isSet.isFalse ? Container(color: Colors.transparent) : Obx(() { _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); return ImagePaint( id: widget.id, zoomCursor: _zoomCursor, cursorOverImage: _cursorOverImage, keyboardEnabled: _keyboardEnabled, remoteCursorMoved: _remoteCursorMoved, listenerBuilder: (child) => _buildRawTouchAndPointerRegion( child, enterView, leaveView), ffi: _ffi, ); }), ); })) ]; if (!_ffi.canvasModel.cursorEmbedded) { paints.add(Obx(() => Offstage( offstage: _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse, child: CursorPaint( id: widget.id, zoomCursor: _zoomCursor, )))); } paints.add( Positioned( top: 10, right: 10, child: _buildRawTouchAndPointerRegion( QualityMonitor(_ffi.qualityMonitorModel), null, null), ), ); return Stack( children: paints, ); } @override bool get wantKeepAlive => true; } class ImagePaint extends StatefulWidget { final FFI ffi; final String id; final RxBool zoomCursor; final RxBool cursorOverImage; final RxBool keyboardEnabled; final RxBool remoteCursorMoved; final Widget Function(Widget)? listenerBuilder; ImagePaint( {Key? key, required this.ffi, required this.id, required this.zoomCursor, required this.cursorOverImage, required this.keyboardEnabled, required this.remoteCursorMoved, this.listenerBuilder}) : super(key: key); @override State createState() => _ImagePaintState(); } class _ImagePaintState extends State { bool _lastRemoteCursorMoved = false; String get id => widget.id; RxBool get zoomCursor => widget.zoomCursor; RxBool get cursorOverImage => widget.cursorOverImage; RxBool get keyboardEnabled => widget.keyboardEnabled; RxBool get remoteCursorMoved => widget.remoteCursorMoved; Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final m = Provider.of(context); var c = Provider.of(context); final s = c.scale; bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive; bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal; mouseRegion({child}) => Obx(() { double getCursorScale() { var c = Provider.of(context); var cursorScale = 1.0; if (isWindows) { // debug win10 if (zoomCursor.value && isViewAdaptive()) { cursorScale = s * c.devicePixelRatio; } } else { if (zoomCursor.value || isViewOriginal()) { cursorScale = s; } } return cursorScale; } return MouseRegion( cursor: cursorOverImage.isTrue ? c.cursorEmbedded ? SystemMouseCursors.none : keyboardEnabled.isTrue ? (() { if (remoteCursorMoved.isTrue) { _lastRemoteCursorMoved = true; return SystemMouseCursors.none; } else { if (_lastRemoteCursorMoved) { _lastRemoteCursorMoved = false; _firstEnterImage.value = true; } return _buildCustomCursor( context, getCursorScale()); } }()) : _buildDisabledCursor(context, getCursorScale()) : MouseCursor.defer, onHover: (evt) {}, child: child); }); if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) { final paintWidth = c.getDisplayWidth() * s; final paintHeight = c.getDisplayHeight() * s; final paintSize = Size(paintWidth, paintHeight); final paintWidget = m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender ? _BuildPaintTextureRender( c, s, Offset.zero, paintSize, isViewOriginal()) : _buildScrollbarNonTextureRender(m, paintSize, s); return NotificationListener( onNotification: (notification) { c.updateScrollPercent(); return false; }, child: mouseRegion( child: Obx(() => _buildCrossScrollbarFromLayout( context, _buildListener(paintWidget), c.size, paintSize, c.scrollHorizontal, c.scrollVertical, )), )); } else { if (c.size.width > 0 && c.size.height > 0) { final paintWidget = m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender ? _BuildPaintTextureRender( c, s, Offset( isLinux ? c.x.toInt().toDouble() : c.x, isLinux ? c.y.toInt().toDouble() : c.y, ), c.size, isViewOriginal()) : _buildScrollAutoNonTextureRender(m, c, s); return mouseRegion(child: _buildListener(paintWidget)); } else { return Container(); } } } Widget _buildScrollbarNonTextureRender( ImageModel m, Size imageSize, double s) { return CustomPaint( size: imageSize, painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s), ); } Widget _buildScrollAutoNonTextureRender( ImageModel m, CanvasModel c, double s) { return CustomPaint( size: Size(c.size.width, c.size.height), painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s), ); } Widget _BuildPaintTextureRender( CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) { final ffiModel = c.parent.target!.ffiModel; final displays = ffiModel.pi.getCurDisplays(); final children = []; final rect = ffiModel.rect; if (rect == null) { return Container(); } final curDisplay = ffiModel.pi.currentDisplay; for (var i = 0; i < displays.length; i++) { final textureId = widget.ffi.textureModel .getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay); if (true) { // both "textureId.value != -1" and "true" seems ok children.add(Positioned( left: (displays[i].x - rect.left) * s + offset.dx, top: (displays[i].y - rect.top) * s + offset.dy, width: displays[i].width * s, height: displays[i].height * s, child: Obx(() => Texture( textureId: textureId.value, filterQuality: isViewOriginal ? FilterQuality.none : FilterQuality.low, )), )); } } return SizedBox( width: size.width, height: size.height, child: Stack(children: children), ); } MouseCursor _buildCustomCursor(BuildContext context, double scale) { final cursor = Provider.of(context); final cache = cursor.cache ?? preDefaultCursor.cache; return buildCursorOfCache(cursor, scale, cache); } MouseCursor _buildDisabledCursor(BuildContext context, double scale) { final cursor = Provider.of(context); final cache = preForbiddenCursor.cache; return buildCursorOfCache(cursor, scale, cache); } Widget _buildCrossScrollbarFromLayout( BuildContext context, Widget child, Size layoutSize, Size size, ScrollController horizontal, ScrollController vertical, ) { final scrollConfig = CustomMouseWheelScrollConfig( scrollDuration: kDefaultScrollDuration, scrollCurve: Curves.linearToEaseOut, mouseWheelTurnsThrottleTimeMs: kDefaultMouseWheelThrottleDuration.inMilliseconds, scrollAmountMultiplier: kDefaultScrollAmountMultiplier); var widget = child; if (layoutSize.width < size.width) { widget = ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( controller: horizontal, scrollDirection: Axis.horizontal, physics: cursorOverImage.isTrue ? const NeverScrollableScrollPhysics() : null, child: widget, ), ); } else { widget = Row( children: [ Container( width: ((layoutSize.width - size.width) ~/ 2).toDouble(), ), widget, ], ); } if (layoutSize.height < size.height) { widget = ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( controller: vertical, physics: cursorOverImage.isTrue ? const NeverScrollableScrollPhysics() : null, child: widget, ), ); } else { widget = Column( children: [ Container( height: ((layoutSize.height - size.height) ~/ 2).toDouble(), ), widget, ], ); } if (layoutSize.width < size.width) { widget = ImprovedScrolling( scrollController: horizontal, enableCustomMouseWheelScrolling: cursorOverImage.isFalse, customMouseWheelScrollConfig: scrollConfig, child: RawScrollbar( thickness: kScrollbarThickness, thumbColor: Colors.grey, controller: horizontal, thumbVisibility: false, trackVisibility: false, notificationPredicate: layoutSize.height < size.height ? (notification) => notification.depth == 1 : defaultScrollNotificationPredicate, child: widget, ), ); } if (layoutSize.height < size.height) { widget = ImprovedScrolling( scrollController: vertical, enableCustomMouseWheelScrolling: cursorOverImage.isFalse, customMouseWheelScrollConfig: scrollConfig, child: RawScrollbar( thickness: kScrollbarThickness, thumbColor: Colors.grey, controller: vertical, thumbVisibility: false, trackVisibility: false, child: widget, ), ); } return Container( child: widget, width: layoutSize.width, height: layoutSize.height, ); } Widget _buildListener(Widget child) { if (listenerBuilder != null) { return listenerBuilder!(child); } else { return child; } } } class CursorPaint extends StatelessWidget { final String id; final RxBool zoomCursor; const CursorPaint({ Key? key, required this.id, required this.zoomCursor, }) : super(key: key); @override Widget build(BuildContext context) { final m = Provider.of(context); final c = Provider.of(context); double hotx = m.hotx; double hoty = m.hoty; if (m.image == null) { if (preDefaultCursor.image != null) { hotx = preDefaultCursor.image!.width / 2; hoty = preDefaultCursor.image!.height / 2; } } double cx = c.x; double cy = c.y; if (c.viewStyle.style == kRemoteViewStyleOriginal && c.scrollStyle == ScrollStyle.scrollbar) { final rect = c.parent.target!.ffiModel.rect; if (rect == null) { // unreachable! debugPrint('unreachable! The displays rect is null.'); return Container(); } if (cx < 0) { final imageWidth = rect.width * c.scale; cx = -imageWidth * c.scrollX; } if (cy < 0) { final imageHeight = rect.height * c.scale; cy = -imageHeight * c.scrollY; } } double x = (m.x - hotx) * c.scale + cx; double y = (m.y - hoty) * c.scale + cy; double scale = 1.0; final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal; if (zoomCursor.value || isViewOriginal) { x = m.x - hotx + cx / c.scale; y = m.y - hoty + cy / c.scale; scale = c.scale; } return CustomPaint( painter: ImagePainter( image: m.image ?? preDefaultCursor.image, x: x, y: y, scale: scale, ), ); } }