// original cm window in Sciter version. import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:get/get.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../common.dart'; import '../../common/widgets/chat_page.dart'; import '../../models/file_model.dart'; import '../../models/platform_model.dart'; import '../../models/server_model.dart'; class DesktopServerPage extends StatefulWidget { const DesktopServerPage({Key? key}) : super(key: key); @override State createState() => _DesktopServerPageState(); } class _DesktopServerPageState extends State with WindowListener, AutomaticKeepAliveClientMixin { final tabController = gFFI.serverModel.tabController; @override void initState() { gFFI.ffiModel.updateEventListener(gFFI.sessionId, ""); windowManager.addListener(this); Get.put(tabController); tabController.onRemoved = (_, id) { onRemoveId(id); }; super.initState(); } @override void dispose() { windowManager.removeListener(this); super.dispose(); } @override void onWindowClose() { Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) { if (Platform.isMacOS) { RdPlatformChannel.instance.terminate(); } else { windowManager.setPreventClose(false); windowManager.close(); } }); super.onWindowClose(); } void onRemoveId(String id) { if (tabController.state.value.tabs.isEmpty) { windowManager.close(); } } @override Widget build(BuildContext context) { super.build(context); return MultiProvider( providers: [ ChangeNotifierProvider.value(value: gFFI.serverModel), ChangeNotifierProvider.value(value: gFFI.chatModel), ], child: Consumer( builder: (context, serverModel, child) => Container( decoration: BoxDecoration( border: Border.all(color: MyTheme.color(context).border!)), child: Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: ConnectionManager(), ), ), ), ); } @override bool get wantKeepAlive => true; } class ConnectionManager extends StatefulWidget { @override State createState() => ConnectionManagerState(); } class ConnectionManagerState extends State { @override void initState() { gFFI.serverModel.updateClientState(); gFFI.serverModel.tabController.onSelected = (client_id_str) { final client_id = int.tryParse(client_id_str); if (client_id != null) { final client = gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == client_id); if (client != null) { gFFI.chatModel.changeCurrentKey(MessageKey(client.peerId, client.id)); if (client.unreadChatMessageCount.value > 0) { Future.delayed(Duration.zero, () { client.unreadChatMessageCount.value = 0; gFFI.chatModel.showChatPage(MessageKey(client.peerId, client.id)); }); } windowManager.setTitle(getWindowNameWithId(client.peerId)); gFFI.cmFileModel.updateCurrentClientId(client.id); } } }; gFFI.chatModel.isConnManager = true; super.initState(); } @override Widget build(BuildContext context) { final serverModel = Provider.of(context); pointerHandler(PointerEvent e) { if (serverModel.cmHiddenTimer != null) { serverModel.cmHiddenTimer!.cancel(); serverModel.cmHiddenTimer = null; debugPrint("CM hidden timer has been canceled"); } } return serverModel.clients.isEmpty ? Column( children: [ buildTitleBar(), Expanded( child: Center( child: Text(translate("Waiting")), ), ), ], ) : Listener( onPointerDown: pointerHandler, onPointerMove: pointerHandler, child: DesktopTab( showTitle: false, showMaximize: false, showMinimize: true, showClose: true, onWindowCloseButton: handleWindowCloseButton, controller: serverModel.tabController, selectedBorderColor: MyTheme.accent, maxLabelWidth: 100, tail: buildScrollJumper(), selectedTabBackgroundColor: Theme.of(context).hintColor.withOpacity(0), tabBuilder: (key, icon, label, themeConf) { final client = serverModel.clients .firstWhereOrNull((client) => client.id.toString() == key); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Tooltip( message: key, waitDuration: Duration(seconds: 1), child: label), unreadMessageCountBuilder(client?.unreadChatMessageCount) .marginOnly(left: 4), ], ); }, pageViewBuilder: (pageView) => LayoutBuilder( builder: (context, constrains) { var borderWidth = 0.0; if (constrains.maxWidth > kConnectionManagerWindowSizeClosedChat.width) { borderWidth = kConnectionManagerWindowSizeOpenChat.width - constrains.maxWidth; } else { borderWidth = kConnectionManagerWindowSizeClosedChat.width - constrains.maxWidth; } if (borderWidth < 0 || borderWidth > 50) { borderWidth = 0; } final realClosedWidth = kConnectionManagerWindowSizeClosedChat.width - borderWidth; final realChatPageWidth = constrains.maxWidth - realClosedWidth; return Row(children: [ if (constrains.maxWidth > kConnectionManagerWindowSizeClosedChat.width) Consumer( builder: (_, model, child) => SizedBox( width: realChatPageWidth, child: buildRemoteBlock( child: Container( decoration: BoxDecoration( border: Border( right: BorderSide( color: Theme.of(context) .dividerColor))), child: buildSidePage()), ), )), SizedBox( width: realClosedWidth, child: SizedBox(width: realClosedWidth, child: pageView)), ]); }, ), ), ); } Widget buildSidePage() { final selected = gFFI.serverModel.tabController.state.value.selected; if (selected < 0 || selected >= gFFI.serverModel.clients.length) { return Offstage(); } final clientType = gFFI.serverModel.clients[selected].type_(); if (clientType == ClientType.file) { return _FileTransferLogPage(); } else { return ChatPage(type: ChatPageType.desktopCM); } } Widget buildTitleBar() { return SizedBox( height: kDesktopRemoteTabBarHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const _AppIcon(), Expanded( child: GestureDetector( onPanStart: (d) { windowManager.startDragging(); }, child: Container( color: Theme.of(context).colorScheme.background, ), ), ), const SizedBox( width: 4.0, ), const _CloseButton() ], ), ); } Widget buildScrollJumper() { final offstage = gFFI.serverModel.clients.length < 2; final sc = gFFI.serverModel.tabController.state.value.scrollController; return Offstage( offstage: offstage, child: Row( children: [ ActionIcon( icon: Icons.arrow_left, iconSize: 22, onTap: sc.backward), ActionIcon( icon: Icons.arrow_right, iconSize: 22, onTap: sc.forward), ], )); } Future handleWindowCloseButton() async { var tabController = gFFI.serverModel.tabController; final connLength = tabController.length; if (connLength <= 1) { windowManager.close(); return true; } else { final opt = "enable-confirm-closing-tabs"; final bool res; if (!option2bool(opt, bind.mainGetLocalOption(key: opt))) { res = true; } else { res = await closeConfirmDialog(); } if (res) { windowManager.close(); } return res; } } } Widget buildConnectionCard(Client client) { return Consumer( builder: (context, value, child) => Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, key: ValueKey(client.id), children: [ _CmHeader(client: client), client.type_() != ClientType.remote || client.disconnected ? Offstage() : _PrivilegeBoard(client: client), Expanded( child: Align( alignment: Alignment.bottomCenter, child: _CmControlPanel(client: client), ), ) ], ).paddingSymmetric(vertical: 4.0, horizontal: 8.0), ); } class _AppIcon extends StatelessWidget { const _AppIcon({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.symmetric(horizontal: 4.0), child: SvgPicture.asset( 'assets/logo.svg', width: 30, height: 30, ), ); } } class _CloseButton extends StatelessWidget { const _CloseButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return IconButton( onPressed: () { windowManager.close(); }, icon: const Icon( IconFont.close, size: 18, ), splashColor: Colors.transparent, hoverColor: Colors.transparent, ); } } class _CmHeader extends StatefulWidget { final Client client; const _CmHeader({Key? key, required this.client}) : super(key: key); @override State<_CmHeader> createState() => _CmHeaderState(); } class _CmHeaderState extends State<_CmHeader> with AutomaticKeepAliveClientMixin { Client get client => widget.client; final _time = 0.obs; Timer? _timer; @override void initState() { super.initState(); _timer = Timer.periodic(Duration(seconds: 1), (_) { if (client.authorized && !client.disconnected) { _time.value = _time.value + 1; } }); gFFI.serverModel.tabController.onSelected?.call(client.id.toString()); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10.0), gradient: LinearGradient( begin: Alignment.topRight, end: Alignment.bottomLeft, colors: [ Color(0xff00bfe1), Color(0xff0071ff), ], ), ), margin: EdgeInsets.symmetric(horizontal: 5.0, vertical: 10.0), padding: EdgeInsets.only( top: 10.0, bottom: 10.0, left: 10.0, right: 5.0, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 70, height: 70, alignment: Alignment.center, decoration: BoxDecoration( color: str2color(client.name), borderRadius: BorderRadius.circular(15.0), ), child: Text( client.name[0], style: TextStyle( fontWeight: FontWeight.bold, color: Colors.white, fontSize: 55, ), ), ).marginOnly(right: 10.0), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ FittedBox( child: Text( client.name, style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20, overflow: TextOverflow.ellipsis, ), maxLines: 1, )), FittedBox( child: Text( "(${client.peerId})", style: TextStyle(color: Colors.white, fontSize: 14), ), ).marginOnly(bottom: 10.0), FittedBox( child: Row( children: [ Text( client.authorized ? client.disconnected ? translate("Disconnected") : translate("Connected") : "${translate("Request access to your device")}...", style: TextStyle(color: Colors.white), ).marginOnly(right: 8.0), if (client.authorized) Obx( () => Text( formatDurationToTime( Duration(seconds: _time.value), ), style: TextStyle(color: Colors.white), ), ) ], )) ], ), ), Offstage( offstage: !client.authorized || (client.type_() != ClientType.remote && client.type_() != ClientType.file), child: IconButton( onPressed: () => checkClickTime(client.id, () { if (client.type_() != ClientType.file) { gFFI.chatModel.toggleCMSidePage(); } else { gFFI.chatModel .toggleCMChatPage(MessageKey(client.peerId, client.id)); } }), icon: SvgPicture.asset(client.type_() == ClientType.file ? 'assets/file_transfer.svg' : 'assets/chat2.svg'), splashRadius: kDesktopIconButtonSplashRadius, ), ) ], ), ); } @override bool get wantKeepAlive => true; } class _PrivilegeBoard extends StatefulWidget { final Client client; const _PrivilegeBoard({Key? key, required this.client}) : super(key: key); @override State createState() => _PrivilegeBoardState(); } class _PrivilegeBoardState extends State<_PrivilegeBoard> { late final client = widget.client; Widget buildPermissionIcon(bool enabled, IconData iconData, Function(bool)? onTap, String tooltipText) { return Tooltip( message: "$tooltipText: ${enabled ? "ON" : "OFF"}", child: Container( decoration: BoxDecoration( color: enabled ? MyTheme.accent : Colors.grey[700], borderRadius: BorderRadius.circular(10.0), ), padding: EdgeInsets.all(8.0), child: InkWell( onTap: () => checkClickTime(widget.client.id, () => onTap?.call(!enabled)), child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Expanded( child: Icon( iconData, color: Colors.white, size: 32, ), ), ], ), ), ), ); } @override Widget build(BuildContext context) { return Container( width: double.infinity, height: 200.0, margin: EdgeInsets.all(5.0), padding: EdgeInsets.all(5.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10.0), color: Theme.of(context).colorScheme.background, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), spreadRadius: 1, blurRadius: 1, offset: Offset(0, 1.5), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( translate("Permissions"), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ).marginOnly(left: 4.0, bottom: 8.0), Expanded( child: GridView.count( crossAxisCount: 3, padding: EdgeInsets.symmetric(horizontal: 20.0), mainAxisSpacing: 20.0, crossAxisSpacing: 20.0, children: [ buildPermissionIcon( client.keyboard, Icons.keyboard, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "keyboard", enabled: enabled); setState(() { client.keyboard = enabled; }); }, translate('Allow using keyboard and mouse'), ), buildPermissionIcon( client.clipboard, Icons.assignment_rounded, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "clipboard", enabled: enabled); setState(() { client.clipboard = enabled; }); }, translate('Allow using clipboard'), ), buildPermissionIcon( client.audio, Icons.volume_up_rounded, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "audio", enabled: enabled); setState(() { client.audio = enabled; }); }, translate('Allow hearing sound'), ), buildPermissionIcon( client.file, Icons.upload_file_rounded, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "file", enabled: enabled); setState(() { client.file = enabled; }); }, translate('Allow file copy and paste'), ), buildPermissionIcon( client.restart, Icons.restart_alt_rounded, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "restart", enabled: enabled); setState(() { client.restart = enabled; }); }, translate('Allow remote restart'), ), buildPermissionIcon( client.recording, Icons.videocam_rounded, (enabled) { bind.cmSwitchPermission( connId: client.id, name: "recording", enabled: enabled); setState(() { client.recording = enabled; }); }, translate('Allow recording session'), ) ], ), ), ], ), ); } } const double buttonBottomMargin = 8; class _CmControlPanel extends StatelessWidget { final Client client; const _CmControlPanel({Key? key, required this.client}) : super(key: key); @override Widget build(BuildContext context) { return client.authorized ? client.disconnected ? buildDisconnected(context) : buildAuthorized(context) : buildUnAuthorized(context); } buildAuthorized(BuildContext context) { final bool canElevate = bind.cmCanElevate(); final model = Provider.of(context); final showElevation = canElevate && model.showElevation && client.type_() == ClientType.remote; return Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Offstage( offstage: !client.inVoiceCall, child: buildButton( context, color: Colors.red, onClick: () => closeVoiceCall(), icon: Icon( Icons.call_end_rounded, color: Colors.white, size: 14, ), text: "Stop voice call", textColor: Colors.white, ), ), Offstage( offstage: !client.incomingVoiceCall, child: Row( children: [ Expanded( child: buildButton(context, color: MyTheme.accent, onClick: () => handleVoiceCall(true), icon: Icon( Icons.call_rounded, color: Colors.white, size: 14, ), text: "Accept", textColor: Colors.white), ), Expanded( child: buildButton( context, color: Colors.red, onClick: () => handleVoiceCall(false), icon: Icon( Icons.phone_disabled_rounded, color: Colors.white, size: 14, ), text: "Dismiss", textColor: Colors.white, ), ) ], ), ), Offstage( offstage: !client.fromSwitch, child: buildButton(context, color: Colors.purple, onClick: () => handleSwitchBack(context), icon: Icon(Icons.reply, color: Colors.white), text: "Switch Sides", textColor: Colors.white), ), Offstage( offstage: !showElevation, child: buildButton( context, color: MyTheme.accent, onClick: () { handleElevate(context); windowManager.minimize(); }, icon: Icon( Icons.security_rounded, color: Colors.white, size: 14, ), text: 'Elevate', textColor: Colors.white, ), ), Row( children: [ Expanded( child: buildButton(context, color: Colors.redAccent, onClick: handleDisconnect, text: 'Disconnect', icon: Icon( Icons.link_off_rounded, color: Colors.white, size: 14, ), textColor: Colors.white), ), ], ) ], ).marginOnly(bottom: buttonBottomMargin); } buildDisconnected(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: buildButton(context, color: MyTheme.accent, onClick: handleClose, text: 'Close', textColor: Colors.white)), ], ).marginOnly(bottom: buttonBottomMargin); } buildUnAuthorized(BuildContext context) { final bool canElevate = bind.cmCanElevate(); final model = Provider.of(context); final showElevation = canElevate && model.showElevation && client.type_() == ClientType.remote; final showAccept = model.approveMode != 'password'; return Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Offstage( offstage: !showElevation || !showAccept, child: buildButton(context, color: Colors.green[700], onClick: () { handleAccept(context); handleElevate(context); windowManager.minimize(); }, text: 'Accept and Elevate', icon: Icon( Icons.security_rounded, color: Colors.white, size: 14, ), textColor: Colors.white, tooltip: 'accept_and_elevate_btn_tooltip'), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (showAccept) Expanded( child: Column( children: [ buildButton( context, color: MyTheme.accent, onClick: () { handleAccept(context); windowManager.minimize(); }, text: 'Accept', textColor: Colors.white, ), ], ), ), Expanded( child: buildButton( context, color: Colors.transparent, border: Border.all(color: Colors.grey), onClick: handleDisconnect, text: 'Cancel', textColor: null, ), ), ], ), ], ).marginOnly(bottom: buttonBottomMargin); } Widget buildButton(BuildContext context, {required Color? color, required Function() onClick, Icon? icon, BoxBorder? border, required String text, required Color? textColor, String? tooltip}) { Widget textWidget; if (icon != null) { textWidget = Text( translate(text), style: TextStyle(color: textColor), textAlign: TextAlign.center, ); } else { textWidget = Expanded( child: Text( translate(text), style: TextStyle(color: textColor), textAlign: TextAlign.center, ), ); } final borderRadius = BorderRadius.circular(10.0); final btn = Container( height: 28, decoration: BoxDecoration( color: color, borderRadius: borderRadius, border: border), child: InkWell( borderRadius: borderRadius, onTap: () => checkClickTime(client.id, onClick), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Offstage(offstage: icon == null, child: icon).marginOnly(right: 5), textWidget, ], ), ), ); return (tooltip != null ? Tooltip( message: translate(tooltip), child: btn, ) : btn) .marginAll(4); } void handleDisconnect() { bind.cmCloseConnection(connId: client.id); } void handleAccept(BuildContext context) { final model = Provider.of(context, listen: false); model.sendLoginResponse(client, true); } void handleElevate(BuildContext context) { final model = Provider.of(context, listen: false); model.setShowElevation(false); bind.cmElevatePortable(connId: client.id); } void handleClose() async { await bind.cmRemoveDisconnectedConnection(connId: client.id); if (await bind.cmGetClientsLength() == 0) { windowManager.close(); } } void handleSwitchBack(BuildContext context) { bind.cmSwitchBack(connId: client.id); } void handleVoiceCall(bool accept) { bind.cmHandleIncomingVoiceCall(id: client.id, accept: accept); } void closeVoiceCall() { bind.cmCloseVoiceCall(id: client.id); } } void checkClickTime(int id, Function() callback) async { var clickCallbackTime = DateTime.now().millisecondsSinceEpoch; await bind.cmCheckClickTime(connId: id); Timer(const Duration(milliseconds: 120), () async { var d = clickCallbackTime - await bind.cmGetClickTime(); if (d > 120) callback(); }); } class _FileTransferLogPage extends StatefulWidget { _FileTransferLogPage({Key? key}) : super(key: key); @override State<_FileTransferLogPage> createState() => __FileTransferLogPageState(); } class __FileTransferLogPageState extends State<_FileTransferLogPage> { @override Widget build(BuildContext context) { return statusList(); } Widget generateCard(Widget child) { return Container( decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.all( Radius.circular(15.0), ), ), child: child, ); } Widget statusList() { return PreferredSize( preferredSize: const Size(200, double.infinity), child: Container( padding: const EdgeInsets.all(12.0), child: Obx( () { final jobTable = gFFI.cmFileModel.currentJobTable; statusListView(List jobs) => ListView.builder( controller: ScrollController(), itemBuilder: (BuildContext context, int index) { final item = jobs[index]; return Padding( padding: const EdgeInsets.only(bottom: 5), child: generateCard( Column( mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 50, child: Column( children: [ Transform.rotate( angle: item.isRemoteToLocal ? 0 : pi, child: SvgPicture.asset( "assets/arrow.svg", color: Theme.of(context) .tabBarTheme .labelColor, ), ), Text(item.isRemoteToLocal ? translate('Send') : translate('Receive')) ], ), ).paddingOnly(left: 15), const SizedBox( width: 16.0, ), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.fileName, ).paddingSymmetric(vertical: 10), if (item.totalSize > 0) Text( '${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}', style: TextStyle( fontSize: 12, color: MyTheme.darkGray, ), ), if (item.totalSize > 0) Offstage( offstage: item.state != JobState.inProgress, child: Text( '${translate("Speed")} ${readableFileSize(item.speed)}/s', style: TextStyle( fontSize: 12, color: MyTheme.darkGray, ), ), ), Offstage( offstage: item.state == JobState.inProgress, child: Text( translate( item.display(), ), style: TextStyle( fontSize: 12, color: MyTheme.darkGray, ), ), ), if (item.totalSize > 0) Offstage( offstage: item.state != JobState.inProgress, child: LinearPercentIndicator( padding: EdgeInsets.only(right: 15), animateFromLastPercent: true, center: Text( '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', ), barRadius: Radius.circular(15), percent: item.finishedSize / item.totalSize, progressColor: MyTheme.accent, backgroundColor: Theme.of(context).hoverColor, lineHeight: kDesktopFileTransferRowHeight, ).paddingSymmetric(vertical: 15), ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [], ), ], ), ], ).paddingSymmetric(vertical: 10), ), ); }, itemCount: jobTable.length, ); return jobTable.isEmpty ? generateCard( Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SvgPicture.asset( "assets/transfer.svg", color: Theme.of(context).tabBarTheme.labelColor, height: 40, ).paddingOnly(bottom: 10), Text( translate("No transfers in progress"), textAlign: TextAlign.center, textScaleFactor: 1.20, style: TextStyle( color: Theme.of(context).tabBarTheme.labelColor), ), ], ), ), ) : statusListView(jobTable); }, )), ); } }