view camera (#11040)

* view camera

Signed-off-by: 21pages <sunboeasy@gmail.com>

* `No cameras` prompt if no cameras available,  `peerGetSessionsCount` use
connType as parameter

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix, use video_service_name rather than display_idx as key in qos,etc

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Adwin White <adwinw01@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
This commit is contained in:
21pages
2025-03-10 21:06:53 +08:00
committed by GitHub
parent df4a101316
commit f0f999dc27
96 changed files with 3999 additions and 458 deletions

487
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,10 @@ import '../consts.dart';
import 'common/widgets/overlay.dart';
import 'mobile/pages/file_manager_page.dart';
import 'mobile/pages/remote_page.dart';
import 'mobile/pages/view_camera_page.dart';
import 'desktop/pages/remote_page.dart' as desktop_remote;
import 'desktop/pages/file_manager_page.dart' as desktop_file_manager;
import 'desktop/pages/view_camera_page.dart' as desktop_view_camera;
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'models/model.dart';
import 'models/platform_model.dart';
@@ -96,6 +98,7 @@ enum DesktopType {
main,
remote,
fileTransfer,
viewCamera,
cm,
portForward,
}
@@ -1750,7 +1753,8 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
await bind.setLocalFlutterOption(
k: windowFramePrefix + type.name, v: pos.toString());
if (type == WindowType.RemoteDesktop && windowId != null) {
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
windowId != null) {
await _saveSessionWindowPosition(
type, windowId, isMaximized, isFullscreen, pos);
}
@@ -1901,7 +1905,9 @@ Future<bool> restoreWindowPosition(WindowType type,
String? pos;
// No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs)
// Though "open in tabs" is true and the new window restore peer position, it's ok.
if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) {
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
windowId != null &&
peerId != null) {
final peerPos = bind.mainGetPeerFlutterOptionSync(
id: peerId, k: windowFramePrefix + type.name);
if (peerPos.isNotEmpty) {
@@ -1916,7 +1922,7 @@ Future<bool> restoreWindowPosition(WindowType type,
debugPrint("no window position saved, ignoring position restoration");
return false;
}
if (type == WindowType.RemoteDesktop) {
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
if (!isRemotePeerPos && windowId != null) {
if (lpos.offsetWidth != null) {
lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset;
@@ -2085,6 +2091,7 @@ StreamSubscription? listenUniLinks({handleByFlutter = true}) {
enum UriLinkType {
remoteDesktop,
fileTransfer,
viewCamera,
portForward,
rdp,
}
@@ -2136,6 +2143,11 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
id = args[i + 1];
i++;
break;
case '--view-camera':
type = UriLinkType.viewCamera;
id = args[i + 1];
i++;
break;
case '--port-forward':
type = UriLinkType.portForward;
id = args[i + 1];
@@ -2177,6 +2189,12 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
password: password, forceRelay: forceRelay);
});
break;
case UriLinkType.viewCamera:
Future.delayed(Duration.zero, () {
rustDeskWinManager.newViewCamera(id!,
password: password, forceRelay: forceRelay);
});
break;
case UriLinkType.portForward:
Future.delayed(Duration.zero, () {
rustDeskWinManager.newPortForward(id!, false,
@@ -2200,7 +2218,14 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
List<String>? urlLinkToCmdArgs(Uri uri) {
String? command;
String? id;
final options = ["connect", "play", "file-transfer", "port-forward", "rdp"];
final options = [
"connect",
"play",
"file-transfer",
"view-camera",
"port-forward",
"rdp"
];
if (uri.authority.isEmpty &&
uri.path.split('').every((char) => char == '/')) {
return [];
@@ -2238,6 +2263,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
connect(Get.context!, id);
} else if (optionIndex == 2) {
connect(Get.context!, id, isFileTransfer: true);
} else if (optionIndex == 3) {
connect(Get.context!, id, isViewCamera: true);
}
return null;
}
@@ -2290,6 +2317,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
connectMainDesktop(String id,
{required bool isFileTransfer,
required bool isViewCamera,
required bool isTcpTunneling,
required bool isRDP,
bool? forceRelay,
@@ -2302,6 +2330,12 @@ connectMainDesktop(String id,
isSharedPassword: isSharedPassword,
connToken: connToken,
forceRelay: forceRelay);
} else if (isViewCamera) {
await rustDeskWinManager.newViewCamera(id,
password: password,
isSharedPassword: isSharedPassword,
connToken: connToken,
forceRelay: forceRelay);
} else if (isTcpTunneling || isRDP) {
await rustDeskWinManager.newPortForward(id, isRDP,
password: password,
@@ -2318,10 +2352,12 @@ connectMainDesktop(String id,
/// Connect to a peer with [id].
/// If [isFileTransfer], starts a session only for file transfer.
/// If [isViewCamera], starts a session only for view camera.
/// If [isTcpTunneling], starts a session only for tcp tunneling.
/// If [isRDP], starts a session only for rdp.
connect(BuildContext context, String id,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false,
bool forceRelay = false,
@@ -2353,6 +2389,7 @@ connect(BuildContext context, String id,
await connectMainDesktop(
id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP,
password: password,
@@ -2363,6 +2400,7 @@ connect(BuildContext context, String id,
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
'id': id,
'isFileTransfer': isFileTransfer,
'isViewCamera': isViewCamera,
'isTcpTunneling': isTcpTunneling,
'isRDP': isRDP,
'password': password,
@@ -2400,6 +2438,31 @@ connect(BuildContext context, String id,
),
);
}
} else if (isViewCamera) {
if (isWeb) {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) =>
desktop_view_camera.ViewCameraPage(
key: ValueKey(id),
id: id,
toolbarState: ToolbarState(),
password: password,
forceRelay: forceRelay,
isSharedPassword: isSharedPassword,
),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ViewCameraPage(
id: id, password: password, isSharedPassword: isSharedPassword),
),
);
}
} else {
if (isWeb) {
Navigator.push(
@@ -2686,6 +2749,8 @@ String getWindowName({WindowType? overrideType}) {
return name;
case WindowType.FileTransfer:
return "File Transfer - $name";
case WindowType.ViewCamera:
return "View Camera - $name";
case WindowType.PortForward:
return "Port Forward - $name";
case WindowType.RemoteDesktop:
@@ -3051,6 +3116,7 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
'peer_id': peerId,
'display': i,
'display_count': pi.displays.length,
'window_type': (kWindowType ?? WindowType.RemoteDesktop).index,
};
if (screenRect != null) {
args['screen_rect'] = {
@@ -3065,12 +3131,12 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
}
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
int? display, Rect? screenRect) async {
WindowType windowType, int? display, Rect? screenRect) async {
if (screenRect == null) {
// Do not restore window position to new connection if there's a pre-session.
// https://github.com/rustdesk/rustdesk/discussions/8825
if (preSessionCount == 0) {
await restoreWindowPosition(WindowType.RemoteDesktop,
await restoreWindowPosition(windowType,
windowId: windowId, display: display, peerId: peerId);
}
} else {

View File

@@ -488,6 +488,7 @@ abstract class BasePeerCard extends StatelessWidget {
BuildContext context,
String title, {
bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false,
}) {
@@ -502,6 +503,7 @@ abstract class BasePeerCard extends StatelessWidget {
peer,
tab,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP,
);
@@ -530,6 +532,15 @@ abstract class BasePeerCard extends StatelessWidget {
);
}
@protected
MenuEntryBase<String> _viewCameraAction(BuildContext context) {
return _connectCommonAction(
context,
translate('View camera'),
isViewCamera: true,
);
}
@protected
MenuEntryBase<String> _tcpTunnelingAction(BuildContext context) {
return _connectCommonAction(
@@ -880,6 +891,7 @@ class RecentPeerCard extends BasePeerCard {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
];
final List favs = (await bind.mainGetFav()).toList();
@@ -939,6 +951,7 @@ class FavoritePeerCard extends BasePeerCard {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -992,6 +1005,7 @@ class DiscoveredPeerCard extends BasePeerCard {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
];
final List favs = (await bind.mainGetFav()).toList();
@@ -1045,6 +1059,7 @@ class AddressBookPeerCard extends BasePeerCard {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -1177,6 +1192,7 @@ class MyGroupPeerCard extends BasePeerCard {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -1398,6 +1414,7 @@ class TagPainter extends CustomPainter {
void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false}) async {
var password = '';
@@ -1423,6 +1440,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
password: password,
isSharedPassword: isSharedPassword,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP);
}

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -53,13 +54,14 @@ class RawKeyFocusScope extends StatelessWidget {
class RawTouchGestureDetectorRegion extends StatefulWidget {
final Widget child;
final FFI ffi;
final bool isCamera;
late final InputModel inputModel = ffi.inputModel;
late final FfiModel ffiModel = ffi.ffiModel;
RawTouchGestureDetectorRegion({
required this.child,
required this.ffi,
this.isCamera = false,
});
@override
@@ -382,6 +384,7 @@ class _RawTouchGestureDetectorRegionState
_scale = d.scale;
if (scale != 0) {
if (widget.isCamera) return;
await bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
@@ -402,6 +405,7 @@ class _RawTouchGestureDetectorRegionState
return;
}
if ((isDesktop || isWebDesktop)) {
if (widget.isCamera) return;
await bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
@@ -536,3 +540,46 @@ class RawPointerMouseRegion extends StatelessWidget {
);
}
}
class CameraRawPointerMouseRegion extends StatelessWidget {
final InputModel inputModel;
final Widget child;
final PointerEnterEventListener? onEnter;
final PointerExitEventListener? onExit;
final PointerDownEventListener? onPointerDown;
final PointerUpEventListener? onPointerUp;
CameraRawPointerMouseRegion({
this.onEnter,
this.onExit,
this.onPointerDown,
this.onPointerUp,
required this.inputModel,
required this.child,
});
@override
Widget build(BuildContext context) {
return Listener(
onPointerHover: (evt) {
final offset = evt.position;
double x = offset.dx;
double y = max(0.0, offset.dy);
inputModel.handlePointerDevicePos(
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
},
onPointerDown: (evt) {
onPointerDown?.call(evt);
},
onPointerUp: (evt) {
onPointerUp?.call(evt);
},
child: MouseRegion(
cursor: MouseCursor.defer,
onEnter: onEnter,
onExit: onExit,
child: child,
),
);
}
}

View File

@@ -89,10 +89,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
List<TTextMenu> v = [];
// elevation
if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) {
if (isDefaultConn &&
perms['keyboard'] != false &&
ffi.elevationModel.showRequestMenu) {
v.add(
TTextMenu(
child: Text(translate('Request Elevation')),
@@ -101,7 +104,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// osAccount / osPassword
if (perms['keyboard'] != false) {
if (isDefaultConn && perms['keyboard'] != false) {
v.add(
TTextMenu(
child: Row(children: [
@@ -130,7 +133,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// paste
if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
if (isDefaultConn &&
pi.platform != kPeerPlatformAndroid &&
perms['keyboard'] != false) {
v.add(TTextMenu(
child: Text(translate('Send clipboard keystrokes')),
onPressed: () async {
@@ -142,43 +147,53 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
}));
}
// reset canvas
if (isMobile) {
if (isDefaultConn && isMobile) {
v.add(TTextMenu(
child: Text(translate('Reset canvas')),
onPressed: () => ffi.cursorModel.reset()));
}
connectWithToken(
{required bool isFileTransfer, required bool isTcpTunneling}) {
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false}) {
final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId);
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
connToken: connToken);
}
// transferFile
if (isDesktop) {
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('Transfer file')),
onPressed: () =>
connectWithToken(isFileTransfer: true, isTcpTunneling: false)),
onPressed: () => connectWithToken(isFileTransfer: true)),
);
}
// viewCamera
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('View camera')),
onPressed: () => connectWithToken(isViewCamera: true)),
);
}
// tcpTunneling
if (isDesktop) {
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('TCP tunneling')),
onPressed: () =>
connectWithToken(isFileTransfer: false, isTcpTunneling: true)),
onPressed: () => connectWithToken(isTcpTunneling: true)),
);
}
// note
if (bind
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
.isNotEmpty) {
if (isDefaultConn &&
bind
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
.isNotEmpty) {
v.add(
TTextMenu(
child: Text(translate('Note')),
@@ -186,11 +201,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// divider
if (isDesktop || isWebDesktop) {
if (isDefaultConn && (isDesktop || isWebDesktop)) {
v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true));
}
// ctrlAltDel
if (!ffiModel.viewOnly &&
if (isDefaultConn &&
!ffiModel.viewOnly &&
ffiModel.keyboard &&
(pi.platform == kPeerPlatformLinux || pi.sasEnabled)) {
v.add(
@@ -200,7 +216,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// restart
if (perms['restart'] != false &&
if (isDefaultConn &&
perms['restart'] != false &&
(pi.platform == kPeerPlatformLinux ||
pi.platform == kPeerPlatformWindows ||
pi.platform == kPeerPlatformMacOS)) {
@@ -212,7 +229,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// insertLock
if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) {
if (isDefaultConn && !ffiModel.viewOnly && ffi.ffiModel.keyboard) {
v.add(
TTextMenu(
child: Text(translate('Insert Lock')),
@@ -220,7 +237,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
);
}
// blockUserInput
if (ffi.ffiModel.keyboard &&
if (isDefaultConn &&
ffi.ffiModel.keyboard &&
ffi.ffiModel.permissions['block_input'] != false &&
pi.platform == kPeerPlatformWindows) // privacy-mode != true ??
{
@@ -236,12 +254,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
}));
}
// switchSides
if (isDesktop &&
if (isDefaultConn &&
isDesktop &&
ffiModel.keyboard &&
pi.platform != kPeerPlatformAndroid &&
pi.platform != kPeerPlatformMacOS &&
versionCmp(pi.version, '1.2.0') >= 0 &&
bind.peerGetDefaultSessionsCount(id: id) == 1) {
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
v.add(TTextMenu(
child: Text(translate('Switch Sides')),
onPressed: () =>
@@ -523,6 +542,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
// show quality monitor
final option = 'show-quality-monitor';
@@ -535,7 +555,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
},
child: Text(translate('Show quality monitor'))));
// mute
if (perms['audio'] != false) {
if (isDefaultConn && perms['audio'] != false) {
final option = 'disable-audio';
final value =
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
@@ -556,7 +576,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 &&
bind.mainHasFileClipboard() &&
pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard);
if (ffiModel.keyboard &&
if (isDefaultConn &&
ffiModel.keyboard &&
perms['file'] != false &&
(isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) {
final enabled = !ffiModel.viewOnly;
@@ -574,7 +595,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('Enable file copy and paste'))));
}
// disable clipboard
if (ffiModel.keyboard && perms['clipboard'] != false) {
if (isDefaultConn && ffiModel.keyboard && perms['clipboard'] != false) {
final enabled = !ffiModel.viewOnly;
final option = 'disable-clipboard';
var value =
@@ -591,7 +612,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('Disable clipboard'))));
}
// lock after session end
if (ffiModel.keyboard && !ffiModel.isPeerAndroid) {
if (isDefaultConn && ffiModel.keyboard && !ffiModel.isPeerAndroid) {
final enabled = !ffiModel.viewOnly;
final option = 'lock-after-session-end';
final value =
@@ -656,12 +677,12 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('True color (4:4:4)'))));
}
if (isMobile) {
if (isDefaultConn && isMobile) {
v.addAll(toolbarKeyboardToggles(ffi));
}
// view mode (mobile only, desktop is in keyboard menu)
if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
if (isDefaultConn && isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
v.add(TToggleMenu(
value: ffiModel.viewOnly,
onChanged: (value) async {

View File

@@ -27,6 +27,7 @@ const String kPlatformAdditionsAmyuniVirtualDisplays =
const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
const String kPlatformAdditionsSupportedPrivacyModeImpl =
"supported_privacy_mode_impl";
const String kPlatformAdditionsSupportViewCamera = "support_view_camera";
const String kPeerPlatformWindows = "Windows";
const String kPeerPlatformLinux = "Linux";
@@ -44,6 +45,7 @@ const String kAppTypeConnectionManager = "cm";
const String kAppTypeDesktopRemote = "remote";
const String kAppTypeDesktopFileTransfer = "file transfer";
const String kAppTypeDesktopViewCamera = "view camera";
const String kAppTypeDesktopPortForward = "port forward";
const String kWindowMainWindowOnTop = "main_window_on_top";
@@ -58,6 +60,7 @@ const String kWindowConnect = "connect";
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
const String kWindowEventNewFileTransfer = "new_file_transfer";
const String kWindowEventNewViewCamera = "new_view_camera";
const String kWindowEventNewPortForward = "new_port_forward";
const String kWindowEventActiveSession = "active_session";
const String kWindowEventActiveDisplaySession = "active_display_session";
@@ -97,6 +100,7 @@ const String kOptionEnableKeyboard = "enable-keyboard";
const String kOptionEnableClipboard = "enable-clipboard";
const String kOptionEnableFileTransfer = "enable-file-transfer";
const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";

View File

@@ -17,7 +17,6 @@ import '../../common/formatter/id_formatter.dart';
import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart';
import '../../models/platform_model.dart';
import '../widgets/button.dart';
class OnlineStatusWidget extends StatefulWidget {
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
@@ -203,6 +202,8 @@ class _ConnectionPageState extends State<ConnectionPage>
final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
String selectedConnectionType = 'Connect';
bool isWindowMinimized = false;
final AllPeersLoader _allPeersLoader = AllPeersLoader();
@@ -321,9 +322,10 @@ class _ConnectionPageState extends State<ConnectionPage>
/// Callback for the connect button.
/// Connects to the selected peer.
void onConnect({bool isFileTransfer = false}) {
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
var id = _idController.id;
connect(context, id, isFileTransfer: isFileTransfer);
connect(context, id,
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
}
/// UI for the remote ID TextField.
@@ -501,21 +503,64 @@ class _ConnectionPageState extends State<ConnectionPage>
),
Padding(
padding: const EdgeInsets.only(top: 13.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Button(
isOutline: true,
onTap: () => onConnect(isFileTransfer: true),
text: "Transfer file",
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
SizedBox(
height: 28.0,
child: ElevatedButton(
onPressed: () {
onConnect();
},
child: Text(translate("Connect")),
),
const SizedBox(
width: 17,
),
const SizedBox(width: 3),
Container(
height: 28.0,
width: 28.0,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
Button(onTap: onConnect, text: "Connect"),
],
),
)
child: Center(
child: MenuAnchor(
builder: (context, controller, builder) {
return IconButton(
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: controller.isOpen
? const Icon(Icons.keyboard_arrow_up)
: const Icon(Icons.keyboard_arrow_down),
onPressed: () {
setState(() {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
});
},
);
},
menuChildren: <Widget>[
MenuItemButton(
onPressed: () {
onConnect(isFileTransfer: true);
},
child: Text(translate('Transfer file')),
),
MenuItemButton(
onPressed: () {
onConnect(isViewCamera: true);
},
child: Text(translate('View camera')),
),
],
),
),
),
]),
),
],
),
),

View File

@@ -775,6 +775,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
await connectMainDesktop(
call.arguments['id'],
isFileTransfer: call.arguments['isFileTransfer'],
isViewCamera: call.arguments['isViewCamera'],
isTcpTunneling: call.arguments['isTcpTunneling'],
isRDP: call.arguments['isRDP'],
password: call.arguments['password'],
@@ -789,9 +790,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
} catch (e) {
debugPrint("Failed to parse window id '${call.arguments}': $e");
}
if (windowId != null) {
WindowType? windowType;
try {
windowType = WindowType.values.byName(args[3]);
} catch (e) {
debugPrint("Failed to parse window type '${call.arguments}': $e");
}
if (windowId != null && windowType != null) {
await rustDeskWinManager.moveTabToNewWindow(
windowId, args[1], args[2]);
windowId, args[1], args[2], windowType);
}
} else if (call.method == kWindowEventOpenMonitorSession) {
final args = jsonDecode(call.arguments);
@@ -799,9 +806,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
final peerId = args['peer_id'] as String;
final display = args['display'] as int;
final displayCount = args['display_count'] as int;
final windowType = args['window_type'] as int;
final screenRect = parseParamScreenRect(args);
await rustDeskWinManager.openMonitorSession(
windowId, peerId, display, displayCount, screenRect);
windowId, peerId, display, displayCount, screenRect, windowType);
} else if (call.method == kWindowEventRemoteWindowCoords) {
final windowId = int.tryParse(call.arguments);
if (windowId != null) {

View File

@@ -960,6 +960,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable audio', kOptionEnableAudio,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(
context, 'Enable TCP tunneling', kOptionEnableTunnel,
enabled: enabled, fakeValue: fakeValue),

View File

@@ -269,8 +269,10 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
style: style,
),
proc: () async {
await DesktopMultiWindow.invokeMethod(kMainWindowId,
kWindowEventMoveTabToNewWindow, '${windowId()},$key,$sessionId');
await DesktopMultiWindow.invokeMethod(
kMainWindowId,
kWindowEventMoveTabToNewWindow,
'${windowId()},$key,$sessionId,RemoteDesktop');
cancelFunc();
},
padding: padding,
@@ -417,8 +419,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
}
await setNewConnectWindowFrame(
windowId(), id!, prePeerCount, display, screenRect);
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
WindowType.RemoteDesktop, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId());
});

View File

@@ -353,7 +353,9 @@ Widget buildConnectionCard(Client client) {
key: ValueKey(client.id),
children: [
_CmHeader(client: client),
client.type_() != ClientType.remote || client.disconnected
client.type_() == ClientType.file ||
client.type_() == ClientType.portForward ||
client.disconnected
? Offstage()
: _PrivilegeBoard(client: client),
Expanded(
@@ -526,7 +528,8 @@ class _CmHeaderState extends State<_CmHeader>
Offstage(
offstage: !client.authorized ||
(client.type_() != ClientType.remote &&
client.type_() != ClientType.file),
client.type_() != ClientType.file &&
client.type_() != ClientType.camera),
child: IconButton(
onPressed: () => checkClickTime(client.id, () {
if (client.type_() == ClientType.file) {
@@ -627,96 +630,139 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
padding: EdgeInsets.symmetric(horizontal: spacing),
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
children: [
buildPermissionIcon(
client.keyboard,
Icons.keyboard,
(enabled) {
bind.cmSwitchPermission(
connId: client.id, name: "keyboard", enabled: enabled);
setState(() {
client.keyboard = enabled;
});
},
translate('Enable keyboard/mouse'),
),
buildPermissionIcon(
client.clipboard,
Icons.assignment_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id, name: "clipboard", enabled: enabled);
setState(() {
client.clipboard = enabled;
});
},
translate('Enable clipboard'),
),
buildPermissionIcon(
client.audio,
Icons.volume_up_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id, name: "audio", enabled: enabled);
setState(() {
client.audio = enabled;
});
},
translate('Enable audio'),
),
buildPermissionIcon(
client.file,
Icons.upload_file_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id, name: "file", enabled: enabled);
setState(() {
client.file = enabled;
});
},
translate('Enable 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('Enable remote restart'),
),
buildPermissionIcon(
client.recording,
Icons.videocam_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id, name: "recording", enabled: enabled);
setState(() {
client.recording = enabled;
});
},
translate('Enable recording session'),
),
// only windows support block input
if (isWindows)
buildPermissionIcon(
client.blockInput,
Icons.block,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "block_input",
enabled: enabled);
setState(() {
client.blockInput = enabled;
});
},
translate('Enable blocking user input'),
)
],
children: client.type_() == ClientType.camera
? [
buildPermissionIcon(
client.audio,
Icons.volume_up_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "audio",
enabled: enabled);
setState(() {
client.audio = enabled;
});
},
translate('Enable audio'),
),
buildPermissionIcon(
client.recording,
Icons.videocam_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "recording",
enabled: enabled);
setState(() {
client.recording = enabled;
});
},
translate('Enable recording session'),
),
]
: [
buildPermissionIcon(
client.keyboard,
Icons.keyboard,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "keyboard",
enabled: enabled);
setState(() {
client.keyboard = enabled;
});
},
translate('Enable keyboard/mouse'),
),
buildPermissionIcon(
client.clipboard,
Icons.assignment_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "clipboard",
enabled: enabled);
setState(() {
client.clipboard = enabled;
});
},
translate('Enable clipboard'),
),
buildPermissionIcon(
client.audio,
Icons.volume_up_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "audio",
enabled: enabled);
setState(() {
client.audio = enabled;
});
},
translate('Enable audio'),
),
buildPermissionIcon(
client.file,
Icons.upload_file_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "file",
enabled: enabled);
setState(() {
client.file = enabled;
});
},
translate('Enable 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('Enable remote restart'),
),
buildPermissionIcon(
client.recording,
Icons.videocam_rounded,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "recording",
enabled: enabled);
setState(() {
client.recording = enabled;
});
},
translate('Enable recording session'),
),
// only windows support block input
if (isWindows)
buildPermissionIcon(
client.blockInput,
Icons.block,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "block_input",
enabled: enabled);
setState(() {
client.blockInput = enabled;
});
},
translate('Enable blocking user input'),
)
],
),
),
],

View File

@@ -0,0 +1,730 @@
import 'dart:async';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_hbb/models/state_model.dart';
import '../../consts.dart';
import '../../common/widgets/overlay.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<bool> _firstEnterImage = SimpleWrapper(false);
// Used to skip session close if "move to new window" is clicked.
final Map<String, bool> closeSessionOnDispose = {};
class ViewCameraPage extends StatefulWidget {
ViewCameraPage({
Key? key,
required this.id,
required this.toolbarState,
this.sessionId,
this.tabWindowId,
this.password,
this.display,
this.displays,
this.tabController,
this.connToken,
this.forceRelay,
this.isSharedPassword,
}) : super(key: key) {
initSharedStates(id);
}
final String id;
final SessionID? sessionId;
final int? tabWindowId;
final int? display;
final List<int>? displays;
final String? password;
final ToolbarState toolbarState;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final SimpleWrapper<State<ViewCameraPage>?> _lastState = SimpleWrapper(null);
final DesktopTabController? tabController;
FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi;
@override
State<ViewCameraPage> createState() {
final state = _ViewCameraPageState(id);
_lastState.value = state;
return state;
}
}
class _ViewCameraPageState extends State<ViewCameraPage>
with AutomaticKeepAliveClientMixin, MultiWindowListener {
Timer? _timer;
String keyboardMode = "legacy";
bool _isWindowBlur = false;
final _cursorOverImage = false.obs;
var _blockableOverlayState = BlockableOverlayState();
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi;
SessionID get sessionId => _ffi.sessionId;
_ViewCameraPageState(String id) {
_initStates(id);
}
void _initStates(String id) {}
@override
void initState() {
super.initState();
_ffi = FFI(widget.sessionId);
Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
_ffi.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
});
_ffi.start(
widget.id,
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
tabWindowId: widget.tabWindowId,
display: widget.display,
displays: widget.displays,
connToken: widget.connToken,
);
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);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
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);
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
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<void> 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.onViewCameraPageDispose(closeSession);
if (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();
_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<FFI>(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: (id, func) {
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
_onEnterOrLeaveImage4Toolbar = func;
},
onEnterOrLeaveImageCleaner: (id) {
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
_onEnterOrLeaveImage4Toolbar = null;
}
},
setRemoteState: setState,
);
bodyWidget() {
return Stack(
children: [
Container(
color: kColorCanvas,
child: getBodyForDesktop(context),
),
Stack(
children: [
_ffi.ffiModel.pi.isSet.isTrue &&
_ffi.ffiModel.waitForFirstImage.isTrue
? emptyOverlay()
: () {
if (!_ffi.ffiModel.isPeerAndroid) {
return Offstage();
} else {
return Obx(() => Offstage(
offstage: _ffi.dialogManager
.mobileActionsOverlayVisible.isFalse,
child: Overlay(initialEntries: [
makeMobileActionsOverlayEntry(
() => _ffi.dialogManager
.setMobileActionsOverlayVisible(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,
isCamera: true,
);
}
Widget _buildRawPointerMouseRegion(
Widget child,
PointerEnterEventListener? onEnter,
PointerExitEventListener? onExit,
) {
return CameraRawPointerMouseRegion(
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 = <Widget>[
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<CanvasModel>(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(() {
widget.toolbarState.initShow(sessionId);
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
cursorOverImage: _cursorOverImage,
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}))
];
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 cursorOverImage;
final Widget Function(Widget)? listenerBuilder;
ImagePaint(
{Key? key,
required this.ffi,
required this.id,
required this.cursorOverImage,
this.listenerBuilder})
: super(key: key);
@override
State<StatefulWidget> createState() => _ImagePaintState();
}
class _ImagePaintState extends State<ImagePaint> {
bool _lastRemoteCursorMoved = false;
String get id => widget.id;
RxBool get cursorOverImage => widget.cursorOverImage;
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
var c = Provider.of<CanvasModel>(context);
final s = c.scale;
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
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<ScrollNotification>(
onNotification: (notification) {
c.updateScrollPercent();
return false;
},
child: Container(
child: _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 Container(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 = <Widget>[];
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<CursorModel>(context);
final cache = cursor.cache ?? preDefaultCursor.cache;
return buildCursorOfCache(cursor, scale, cache);
}
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
final cursor = Provider.of<CursorModel>(context);
final cache = preForbiddenCursor.cache;
return buildCursorOfCache(cursor, scale, cache);
}
Widget _buildCrossScrollbarFromLayout(
BuildContext context,
Widget child,
Size layoutSize,
Size size,
ScrollController horizontal,
ScrollController vertical,
) {
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 = 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 = 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;
}
}
}

View File

@@ -0,0 +1,499 @@
import 'dart:convert';
import 'dart:async';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:bot_toast/bot_toast.dart';
import '../../models/platform_model.dart';
class _MenuTheme {
static const Color blueColor = MyTheme.button;
// kMinInteractiveDimension
static const double height = 20.0;
static const double dividerHeight = 12.0;
}
class ViewCameraTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const ViewCameraTabPage({Key? key, required this.params}) : super(key: key);
@override
State<ViewCameraTabPage> createState() => _ViewCameraTabPageState(params);
}
class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
final tabController =
Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera));
final contentKey = UniqueKey();
static const IconData selectedIcon = Icons.desktop_windows_sharp;
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
String? peerId;
bool _isScreenRectSet = false;
int? _display;
var connectionMap = RxList<Widget>.empty(growable: true);
_ViewCameraTabPageState(Map<String, dynamic> params) {
RemoteCountState.init();
peerId = params['id'];
final sessionId = params['session_id'];
final tabWindowId = params['tab_window_id'];
final display = params['display'];
final displays = params['displays'];
final screenRect = parseParamScreenRect(params);
_isScreenRectSet = screenRect != null;
_display = display as int?;
tryMoveToScreenAndSetFullscreen(screenRect);
if (peerId != null) {
ConnectionTypeState.init(peerId!);
tabController.onSelected = (id) {
final viewCameraPage = tabController.widget(id);
if (viewCameraPage is ViewCameraPage) {
final ffi = viewCameraPage.ffi;
bind.setCurSessionId(sessionId: ffi.sessionId);
}
WindowController.fromWindowId(params['windowId'])
.setTitle(getWindowNameWithId(id));
UnreadChatCountState.find(id).value = 0;
};
tabController.add(TabInfo(
key: peerId!,
label: peerId!,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(peerId),
page: ViewCameraPage(
key: ValueKey(peerId),
id: peerId!,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: params['password'],
toolbarState: ToolbarState(),
tabController: tabController,
connToken: params['connToken'],
forceRelay: params['forceRelay'],
isSharedPassword: params['isSharedPassword'],
),
));
_update_remote_count();
}
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
}
@override
void initState() {
super.initState();
if (!_isScreenRectSet) {
Future.delayed(Duration.zero, () {
restoreWindowPosition(
WindowType.ViewCamera,
windowId: windowId(),
peerId: tabController.state.value.tabs.isEmpty
? null
: tabController.state.value.tabs[0].key,
display: _display,
);
});
}
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
tabBuilder: (key, icon, label, themeConf) => Obx(() {
final connectionType = ConnectionTypeState.find(key);
if (!connectionType.isValid()) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
label,
],
);
} else {
bool secure =
connectionType.secure.value == ConnectionType.strSecure;
bool direct =
connectionType.direct.value == ConnectionType.strDirect;
String msgConn;
if (secure && direct) {
msgConn = translate("Direct and encrypted connection");
} else if (secure && !direct) {
msgConn = translate("Relayed and encrypted connection");
} else if (!secure && direct) {
msgConn = translate("Direct and unencrypted connection");
} else {
msgConn = translate("Relayed and unencrypted connection");
}
var msgFingerprint = '${translate('Fingerprint')}:\n';
var fingerprint = FingerprintState.find(key).value;
if (fingerprint.isEmpty) {
fingerprint = 'N/A';
}
if (fingerprint.length > 5 * 8) {
var first = fingerprint.substring(0, 39);
var second = fingerprint.substring(40);
msgFingerprint += '$first\n$second';
} else {
msgFingerprint += fingerprint;
}
final tab = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Tooltip(
message: '$msgConn\n$msgFingerprint',
child: SvgPicture.asset(
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
width: themeConf.iconSize,
height: themeConf.iconSize,
).paddingOnly(right: 5),
),
label,
unreadMessageCountBuilder(UnreadChatCountState.find(key))
.marginOnly(left: 4),
],
);
return Listener(
onPointerDown: (e) {
if (e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as ViewCameraPage;
if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue &&
e.buttons == 2) {
showRightMenu(
(CancelFunc cancelFunc) {
return _tabMenuBuilder(key, cancelFunc);
},
target: e.position,
);
}
},
child: tab,
);
}
}),
),
);
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Obx(() => Container(
decoration: BoxDecoration(
border: Border.all(
color: MyTheme.color(context).border!,
width: stateGlobal.windowBorderWidth.value),
),
child: child,
)));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: Obx(() => SubWindowDragToResizeArea(
key: contentKey,
child: tabWidget,
// Specially configured for a better resize area and remote control.
childPadding: kDragToResizeAreaPadding,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
));
}
// Note: Some dup code to ../widgets/remote_toolbar
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as ViewCameraPage;
final ffi = viewCameraPage.ffi;
final sessionId = ffi.sessionId;
final toolbarState = viewCameraPage.toolbarState;
menu.addAll([
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
style: style,
)),
proc: () {
toolbarState.switchShow(sessionId);
cancelFunc();
},
padding: padding,
),
]);
if (tabController.state.value.tabs.length > 1) {
final splitAction = MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Move tab to new window'),
style: style,
),
proc: () async {
await DesktopMultiWindow.invokeMethod(
kMainWindowId,
kWindowEventMoveTabToNewWindow,
'${windowId()},$key,$sessionId,ViewCamera');
cancelFunc();
},
padding: padding,
);
menu.insert(1, splitAction);
}
menu.addAll([
MenuEntryDivider<String>(),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Copy Fingerprint'),
style: style,
),
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
padding: padding,
dismissOnClicked: true,
dismissCallback: cancelFunc,
),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Close'),
style: style,
),
proc: () {
tabController.closeBy(key);
cancelFunc();
},
padding: padding,
)
]);
return mod_menu.PopupMenu<String>(
items: menu
.map((entry) => entry.build(
context,
const MenuConfig(
commonColor: _MenuTheme.blueColor,
height: _MenuTheme.height,
dividerHeight: _MenuTheme.dividerHeight,
)))
.expand((i) => i)
.toList(),
);
}
void onRemoveId(String id) async {
if (tabController.state.value.tabs.isEmpty) {
// Keep calling until the window status is hidden.
//
// Workaround for Windows:
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
// `await WindowController.fromWindowId(windowId()).close();`.
Future<void> loopCloseWindow() async {
int c = 0;
final windowController = WindowController.fromWindowId(windowId());
while (c < 20 &&
tabController.state.value.tabs.isEmpty &&
(!await windowController.isHidden())) {
await windowController.close();
await Future.delayed(Duration(milliseconds: 100));
c++;
}
}
loopCloseWindow();
}
ConnectionTypeState.delete(id);
_update_remote_count();
}
int windowId() {
return widget.params["windowId"];
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.length;
if (connLength <= 1) {
tabController.clear();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
}
return res;
}
}
_update_remote_count() =>
RemoteCountState.find().value = tabController.length;
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
debugPrint(
"[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
dynamic returnValue;
// for simplify, just replace connectionId
if (call.method == kWindowEventNewViewCamera) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final sessionId = args['session_id'];
final tabWindowId = args['tab_window_id'];
final display = args['display'];
final displays = args['displays'];
final screenRect = parseParamScreenRect(args);
final prePeerCount = tabController.length;
Future.delayed(Duration.zero, () async {
if (stateGlobal.fullscreen.isTrue) {
await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
}
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
WindowType.ViewCamera, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId());
});
});
ConnectionTypeState.init(id);
tabController.add(TabInfo(
key: id,
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => tabController.closeBy(id),
page: ViewCameraPage(
key: ValueKey(id),
id: id,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: args['password'],
toolbarState: ToolbarState(),
tabController: tabController,
connToken: args['connToken'],
forceRelay: args['forceRelay'],
isSharedPassword: args['isSharedPassword'],
),
));
} else if (call.method == kWindowDisableGrabKeyboard) {
// ???
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
final jumpOk = tabController.jumpToByKey(call.arguments);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventActiveDisplaySession) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final display = args['display'];
final jumpOk =
tabController.jumpToByKeyAndDisplay(id, display, isCamera: true);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventGetRemoteList) {
return tabController.state.value.tabs
.map((e) => e.key)
.toList()
.join(',');
} else if (call.method == kWindowEventGetSessionIdList) {
return tabController.state.value.tabs
.map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}')
.toList()
.join(';');
} else if (call.method == kWindowEventGetCachedSessionData) {
// Ready to show new window and close old tab.
final args = jsonDecode(call.arguments);
final id = args['id'];
final close = args['close'];
try {
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id)
.page as ViewCameraPage;
returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) {
debugPrint('Failed to get cached session data: $e');
}
if (close && returnValue != null) {
closeSessionOnDispose[id] = false;
tabController.closeBy(id);
}
} else if (call.method == kWindowEventRemoteWindowCoords) {
final viewCameraPage =
tabController.state.value.selectedTabInfo.page as ViewCameraPage;
final ffi = viewCameraPage.ffi;
final displayRect = ffi.ffiModel.displaysRect();
if (displayRect != null) {
final wc = WindowController.fromWindowId(windowId());
Rect? frame;
try {
frame = await wc.getFrame();
} catch (e) {
debugPrint(
"Failed to get frame of window $windowId, it may be hidden");
}
if (frame != null) {
ffi.cursorModel.moveLocal(0, 0);
final coords = RemoteWindowCoords(
frame,
CanvasCoords.fromCanvasModel(ffi.canvasModel),
CursorCoords.fromCursorModel(ffi.cursorModel),
displayRect);
returnValue = jsonEncode(coords.toJson());
}
}
} else if (call.method == kWindowEventSetFullscreen) {
stateGlobal.setFullscreen(call.arguments == 'true');
}
_update_remote_count();
return returnValue;
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:provider/provider.dart';
/// multi-tab desktop remote screen
class DesktopViewCameraScreen extends StatelessWidget {
final Map<String, dynamic> params;
DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) {
bind.mainInitInputSource();
stateGlobal.getInputSource(force: true);
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
ChangeNotifierProvider.value(value: gFFI.imageModel),
ChangeNotifierProvider.value(value: gFFI.cursorModel),
ChangeNotifierProvider.value(value: gFFI.canvasModel),
],
child: Scaffold(
// Set transparent background for padding the resize area out of the flutter view.
// This allows the wallpaper goes through our resize area. (Linux only now).
backgroundColor: isLinux ? Colors.transparent : null,
body: ViewCameraTabPage(
params: params,
),
));
}
}

View File

@@ -478,7 +478,10 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
state: widget.state,
setFullscreen: _setFullscreen,
));
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
// Do not show keyboard for camera connection type.
if (widget.ffi.connType == ConnType.defaultConn) {
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
}
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
if (!isWeb) {
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
@@ -1043,23 +1046,26 @@ class _DisplayMenuState extends State<_DisplayMenu> {
scrollStyle(),
imageQuality(),
codec(),
_ResolutionsMenu(
id: widget.id,
ffi: widget.ffi,
screenAdjustor: _screenAdjustor,
),
if (showVirtualDisplayMenu(ffi))
if (ffi.connType == ConnType.defaultConn)
_ResolutionsMenu(
id: widget.id,
ffi: widget.ffi,
screenAdjustor: _screenAdjustor,
),
if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn)
_SubmenuButton(
ffi: widget.ffi,
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
child: Text(translate("Virtual display")),
),
cursorToggles(),
if (ffi.connType == ConnType.defaultConn) cursorToggles(),
Divider(),
toggles(),
];
// privacy mode
if (ffiModel.keyboard && pi.features.privacyMode) {
if (ffi.connType == ConnType.defaultConn &&
ffiModel.keyboard &&
pi.features.privacyMode) {
final privacyModeState = PrivacyModeState.find(id);
final privacyModeList =
toolbarPrivacyMode(privacyModeState, context, id, ffi);
@@ -1085,7 +1091,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
]);
}
}
menuChildren.add(widget.pluginItem);
if (ffi.connType == ConnType.defaultConn) {
menuChildren.add(widget.pluginItem);
}
return menuChildren;
}

View File

@@ -9,6 +9,7 @@ import 'package:flutter/material.dart' hide TabBarTheme;
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
@@ -51,6 +52,7 @@ enum DesktopTabType {
cm,
remoteScreen,
fileTransfer,
viewCamera,
portForward,
install,
}
@@ -179,11 +181,13 @@ class DesktopTabController {
jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key),
callOnSelected: callOnSelected);
bool jumpToByKeyAndDisplay(String key, int display) {
bool jumpToByKeyAndDisplay(String key, int display, {bool isCamera = false}) {
for (int i = 0; i < state.value.tabs.length; i++) {
final tab = state.value.tabs[i];
if (tab.key == key) {
final ffi = (tab.page as RemotePage).ffi;
final ffi = isCamera
? (tab.page as ViewCameraPage).ffi
: (tab.page as RemotePage).ffi;
if (ffi.ffiModel.pi.currentDisplay == display) {
return jumpTo(i, callOnSelected: true);
}
@@ -725,6 +729,7 @@ class WindowActionPanelState extends State<WindowActionPanel> {
return widget.tabController.state.value.tabs.length > 1 &&
(widget.tabController.tabType == DesktopTabType.remoteScreen ||
widget.tabController.tabType == DesktopTabType.fileTransfer ||
widget.tabController.tabType == DesktopTabType.viewCamera ||
widget.tabController.tabType == DesktopTabType.portForward ||
widget.tabController.tabType == DesktopTabType.cm);
}

View File

@@ -11,6 +11,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/pages/install_page.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart';
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
@@ -76,6 +77,13 @@ Future<void> main(List<String> args) async {
kAppTypeDesktopFileTransfer,
);
break;
case WindowType.ViewCamera:
desktopType = DesktopType.viewCamera;
runMultiWindow(
argument,
kAppTypeDesktopViewCamera,
);
break;
case WindowType.PortForward:
desktopType = DesktopType.portForward;
runMultiWindow(
@@ -192,6 +200,12 @@ void runMultiWindow(
params: argument,
);
break;
case kAppTypeDesktopViewCamera:
draggablePositions.load();
widget = DesktopViewCameraScreen(
params: argument,
);
break;
case kAppTypeDesktopPortForward:
widget = DesktopPortForwardScreen(
params: argument,
@@ -227,6 +241,19 @@ void runMultiWindow(
await restoreWindowPosition(WindowType.FileTransfer,
windowId: kWindowId!);
break;
case kAppTypeDesktopViewCamera:
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
if (argument['screen_rect'] == null) {
// display can be used to control the offset of the window.
await restoreWindowPosition(
WindowType.ViewCamera,
windowId: kWindowId!,
peerId: argument['id'] as String?,
// FIXME: fix display index.
display: argument['display'] as int?,
);
}
break;
case kAppTypeDesktopPortForward:
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
break;

View File

@@ -204,6 +204,7 @@ class WebHomePage extends StatelessWidget {
return;
}
bool isFileTransfer = false;
bool isViewCamera = false;
String? id;
String? password;
for (int i = 0; i < args.length; i++) {
@@ -219,6 +220,11 @@ class WebHomePage extends StatelessWidget {
id = args[i + 1];
i++;
break;
case '--view-camera':
isViewCamera = true;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
@@ -228,7 +234,7 @@ class WebHomePage extends StatelessWidget {
}
}
if (id != null) {
connect(context, id, isFileTransfer: isFileTransfer, password: password);
connect(context, id, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, password: password);
}
}
}

View File

@@ -0,0 +1,721 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/remote_input.dart';
import '../../models/input_model.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../utils/image.dart';
final initText = '1' * 1024;
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
// https://github.com/flutter/flutter/issues/159384
// https://github.com/flutter/flutter/issues/159383
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
if (isAndroid) {
if (isKeyboardVisible != true) {
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
gFFI.invokeMethod("enable_soft_keyboard", false);
}
}
}
class ViewCameraPage extends StatefulWidget {
ViewCameraPage(
{Key? key, required this.id, this.password, this.isSharedPassword})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
@override
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
}
class _ViewCameraPageState extends State<ViewCameraPage>
with WidgetsBindingObserver {
Timer? _timer;
bool _showBar = !isWebDesktop;
bool _showGestureHelp = false;
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
final keyboardVisibilityController = KeyboardVisibilityController();
final FocusNode _mobileFocusNode = FocusNode();
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
final TextEditingController _textController =
TextEditingController(text: initText);
_ViewCameraPageState(String id) {
initSharedStates(id);
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
gFFI.dialogManager.loadMobileActionsOverlayVisible();
}
@override
void initState() {
super.initState();
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
gFFI.start(
widget.id,
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
if (!isWeb) {
WakelockPlus.enable();
}
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
gFFI.chatModel
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
_blockableOverlayState.applyFfi(gFFI);
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
gFFI.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
if (gFFI.recordingModel.start) {
showToast(translate('Automatically record outgoing sessions'));
}
_disableAndroidSoftKeyboard(
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
gFFI.inputModel.listenToMouse(false);
gFFI.imageModel.disposeImage();
gFFI.cursorModel.disposeImages();
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
if (!isWeb) {
await WakelockPlus.disable();
}
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
// Only one client is considered here for now.
gFFI.chatModel.onVoiceCallClosed("End connetion");
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {}
@override
void didChangeMetrics() {
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
// Don't try reset the view style and focus the cursor.
if (gFFI.cursorModel.lastKeyboardIsVisible &&
gFFI.canvasModel.isMobileCanvasChanged) {
return;
}
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
_timerDidChangeMetrics?.cancel();
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
if (newBottom != _viewInsetsBottom) {
gFFI.canvasModel.mobileFocusCanvasCursor();
_viewInsetsBottom = newBottom;
}
});
}
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
// But I don't know why and how to fix it.
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
/// see override build() in [BlockableOverlay]
state: _blockableOverlayState,
underlying: Container(
color: bgColor,
),
);
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
? getBottomAppBar()
: Offstage());
@override
Widget build(BuildContext context) {
final keyboardIsVisible =
keyboardVisibilityController.isVisible && _showEdit;
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI.dialogManager);
return false;
},
child: Scaffold(
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
floatingActionButtonLocation: keyboardIsVisible
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
: null,
floatingActionButton: !showActionButton
? null
: FloatingActionButton(
mini: !keyboardIsVisible,
child: Icon(
(keyboardIsVisible || _showGestureHelp)
? Icons.expand_more
: Icons.expand_less,
color: Colors.white,
),
backgroundColor: MyTheme.accent,
onPressed: () {
setState(() {
if (keyboardIsVisible) {
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
} else if (_showGestureHelp) {
_showGestureHelp = false;
} else {
_showBar = !_showBar;
}
});
}),
bottomNavigationBar: Obx(() => Stack(
alignment: Alignment.bottomCenter,
children: [
gFFI.ffiModel.pi.isSet.isTrue &&
gFFI.ffiModel.waitForFirstImage.isTrue
? emptyOverlay(MyTheme.canvasColor)
: () {
gFFI.ffiModel.tryShowAndroidActionsOverlay();
return Offstage();
}(),
_bottomWidget(),
gFFI.ffiModel.pi.isSet.isFalse
? emptyOverlay(MyTheme.canvasColor)
: Offstage(),
],
)),
body: Obx(
() => getRawPointerAndKeyBody(Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return Container(
color: kColorCanvas,
child: SafeArea(
child: OrientationBuilder(builder: (ctx, orientation) {
if (_currentOrientation != orientation) {
Timer(const Duration(milliseconds: 200), () {
gFFI.dialogManager
.resetMobileActionsOverlay(ffi: gFFI);
_currentOrientation = orientation;
gFFI.canvasModel.updateViewStyle();
});
}
return Container(
color: MyTheme.canvasColor,
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),
);
})
],
)),
)),
);
}
Widget getRawPointerAndKeyBody(Widget child) {
return CameraRawPointerMouseRegion(
inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established.
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
child: gFFI.ffiModel.pi.isSet.isTrue
? RawKeyFocusScope(
focusNode: _physicalFocusNode,
inputModel: inputModel,
child: child)
: child,
);
}
Widget getBottomAppBar() {
return BottomAppBar(
elevation: 10,
color: MyTheme.accent,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
IconButton(
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI.dialogManager);
},
),
IconButton(
color: Colors.white,
icon: Icon(Icons.tv),
onPressed: () {
setState(() => _showEdit = false);
showOptions(context, widget.id, gFFI.dialogManager);
},
)
] +
(isWeb
? []
: <Widget>[
futureBuilder(
future: gFFI.invokeMethod(
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
hasData: (isSupportVoiceCall) => IconButton(
color: Colors.white,
icon: isAndroid && isSupportVoiceCall
? SvgPicture.asset('assets/chat.svg',
colorFilter: ColorFilter.mode(
Colors.white, BlendMode.srcIn))
: Icon(Icons.message),
onPressed: () =>
isAndroid && isSupportVoiceCall
? showChatOptions(widget.id)
: onPressedTextChat(widget.id),
))
]) +
[
IconButton(
color: Colors.white,
icon: Icon(Icons.more_vert),
onPressed: () {
setState(() => _showEdit = false);
showActions(widget.id);
},
),
]),
Obx(() => IconButton(
color: Colors.white,
icon: Icon(Icons.expand_more),
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
? null
: () {
setState(() => _showBar = !_showBar);
},
)),
],
),
);
}
Widget getBodyForMobile() {
return Container(
color: MyTheme.canvasColor,
child: Stack(children: () {
final paints = [
ImagePaint(),
Positioned(
top: 10,
right: 10,
child: QualityMonitor(gFFI.qualityMonitorModel),
),
SizedBox(
width: 0,
height: 0,
child: !_showEdit
? Container()
: TextFormField(
textInputAction: TextInputAction.newline,
autocorrect: false,
// Flutter 3.16.9 Android.
// `enableSuggestions` causes secure keyboard to be shown.
// https://github.com/flutter/flutter/issues/139143
// https://github.com/flutter/flutter/issues/146540
// enableSuggestions: false,
autofocus: true,
focusNode: _mobileFocusNode,
maxLines: null,
controller: _textController,
// trick way to make backspace work always
keyboardType: TextInputType.multiline,
// `onChanged` may be called depending on the input method if this widget is wrapped in
// `Focus(onKeyEvent: ..., child: ...)`
// For `Backspace` button in the soft keyboard:
// en/fr input method:
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
// 2. The button will trigger `onKeyEvent` if the text field is empty.
// ko/zh/ja input method: the button will trigger `onKeyEvent`
// and the event will not popup if `KeyEventResult.handled` is returned.
onChanged: null,
).workaroundFreezeLinuxMint(),
),
];
return paints;
}()));
}
Widget getBodyForDesktopWithListener() {
var paints = <Widget>[ImagePaint()];
return Container(
color: MyTheme.canvasColor, child: Stack(children: paints));
}
List<TTextMenu> _getMobileActionMenus() {
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
!gFFI.ffiModel.keyboard) {
return [];
}
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
TTextMenu(
child: Text(translate('Back')),
onPressed: () => gFFI.inputModel.onMobileBack(),
),
TTextMenu(
child: Text(translate('Home')),
onPressed: () => gFFI.inputModel.onMobileHome(),
),
TTextMenu(
child: Text(translate('Apps')),
onPressed: () => gFFI.inputModel.onMobileApps(),
),
TTextMenu(
child: Text(translate('Volume up')),
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
),
TTextMenu(
child: Text(translate('Volume down')),
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
),
TTextMenu(
child: Text(translate('Power')),
onPressed: () => gFFI.inputModel.onMobilePower(),
),
];
}
void showActions(String id) async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
final mobileActionMenus = _getMobileActionMenus();
final menus = toolbarControls(context, id, gFFI);
final List<PopupMenuEntry<int>> more = [
...mobileActionMenus
.asMap()
.entries
.map((e) =>
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
child: e.value.getChild(),
value: e.key + mobileActionMenus.length))
.toList(),
];
() async {
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: more,
elevation: 8,
);
if (index != null) {
if (index < mobileActionMenus.length) {
mobileActionMenus[index].onPressed.call();
} else if (index < mobileActionMenus.length + more.length) {
menus[index - mobileActionMenus.length].onPressed.call();
}
}
}();
}
onPressedTextChat(String id) {
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
gFFI.chatModel.toggleChatOverlay();
}
showChatOptions(String id) async {
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
{TextStyle? labelStyle}) =>
TTextMenu(
child: Text(translate(label), style: labelStyle),
trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
child: IgnorePointer(
child: IconButton(
onPressed: null,
icon: icon,
),
),
),
onPressed: onPressed,
);
final isInVoice = [
VoiceCallStatus.waitingForResponse,
VoiceCallStatus.connected
].contains(gFFI.chatModel.voiceCallStatus.value);
final menus = [
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
() => onPressedTextChat(widget.id)),
isInVoice
? makeTextMenu(
'End voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter:
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
),
onPressEndVoiceCall,
labelStyle: TextStyle(color: Colors.redAccent))
: makeTextMenu(
'Voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
),
onPressVoiceCall),
];
final menuItems = menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList();
Future.delayed(Duration.zero, () async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: menuItems,
elevation: 8,
);
if (index != null && index < menus.length) {
menus[index].onPressed.call();
}
});
}
}
class ImagePaint extends StatelessWidget {
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
final c = Provider.of<CanvasModel>(context);
var s = c.scale;
final adjust = c.getAdjustY();
return CustomPaint(
painter: ImagePainter(
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
);
}
}
void showOptions(
BuildContext context, String id, OverlayDialogManager dialogManager) async {
var displays = <Widget>[];
final pi = gFFI.ffiModel.pi;
final image = gFFI.ffiModel.getConnectionImage();
if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
}
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
for (var i = 0; i < pi.displays.length; ++i) {
children.add(InkWell(
onTap: () {
if (i == cur) return;
openMonitorInTheSameTab(i, gFFI, pi);
gFFI.dialogManager.dismissAll();
},
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).hintColor),
borderRadius: BorderRadius.circular(2),
color: i == cur
? Theme.of(context).primaryColor.withOpacity(0.6)
: null),
child: Center(
child: Text((i + 1).toString(),
style: TextStyle(
color: i == cur ? Colors.white : Colors.black87,
fontWeight: FontWeight.bold))))));
}
displays.add(Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: children,
)));
}
if (displays.isNotEmpty) {
displays.add(const Divider(color: MyTheme.border));
}
List<TRadioMenu<String>> viewStyleRadios =
await toolbarViewStyle(context, id, gFFI);
List<TRadioMenu<String>> imageQualityRadios =
await toolbarImageQuality(context, id, gFFI);
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
dialogManager.show((setState, close, context) {
var viewStyle =
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
var imageQuality =
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
.obs;
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
final radios = [
for (var e in viewStyleRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
viewStyle.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) viewStyle.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in imageQualityRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
imageQuality.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) imageQuality.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in codecRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
codec.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) codec.value = v;
}
: null)),
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
];
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
final displayTogglesList = displayToggles
.asMap()
.entries
.map((e) => Obx(() => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
value: rxToggleValues[e.key].value,
onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v;
}
: null,
title: e.value.child)))
.toList();
final toggles = [
...displayTogglesList,
];
var popupDialogMenus = List<Widget>.empty(growable: true);
if (popupDialogMenus.isNotEmpty) {
popupDialogMenus.add(const Divider(color: MyTheme.border));
}
return CustomAlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: displays + radios + popupDialogMenus + toggles),
);
}, clickMaskDismiss: true, backDismiss: true).then((value) {
_disableAndroidSoftKeyboard();
});
}
class FABLocation extends FloatingActionButtonLocation {
FloatingActionButtonLocation location;
double offsetX;
double offsetY;
FABLocation(this.location, this.offsetX, this.offsetY);
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
final offset = location.getOffset(scaffoldGeometry);
return Offset(offset.dx + offsetX, offset.dy + offsetY);
}
}

View File

@@ -235,6 +235,17 @@ class TextureModel {
}
}
onViewCameraPageDispose(bool closeSession) async {
final ffi = parent.target;
if (ffi == null) return;
for (final texture in _pixelbufferRenderTextures.values) {
await texture.destroy(closeSession, ffi);
}
for (final texture in _gpuRenderTextures.values) {
await texture.destroy(closeSession, ffi);
}
}
ensureControl(int display) {
var ctl = _control[display];
if (ctl == null) {

View File

@@ -369,6 +369,7 @@ class InputModel {
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
InputModel(this.parent) {
sessionId = parent.target!.sessionId;
@@ -471,6 +472,7 @@ class InputModel {
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
@@ -525,6 +527,7 @@ class InputModel {
KeyEventResult handleKeyEvent(KeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
@@ -724,6 +727,7 @@ class InputModel {
/// [press] indicates a click event(down and up).
void inputKey(String name, {bool? down, bool? press}) {
if (!keyboardPerm) return;
if (isViewCamera) return;
bind.sessionInputKey(
sessionId: sessionId,
name: name,
@@ -785,6 +789,7 @@ class InputModel {
/// Send scroll event with scroll distance [y].
Future<void> scroll(int y) async {
if (isViewCamera) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json
@@ -808,6 +813,7 @@ class InputModel {
/// Send mouse press event.
Future<void> sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
@@ -834,6 +840,7 @@ class InputModel {
/// Send mouse movement event with distance in [x] and [y].
Future<void> moveMouse(double x, double y) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
var x2 = x.toInt();
var y2 = y.toInt();
await bind.sessionSendMouse(
@@ -857,6 +864,7 @@ class InputModel {
_lastScale = 1.0;
_stopFling = true;
if (isViewOnly) return;
if (isViewCamera) return;
if (peerPlatform == kPeerPlatformAndroid) {
handlePointerEvent('touch', kMouseEventTypePanStart, e.position);
}
@@ -865,6 +873,7 @@ class InputModel {
// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures
void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
if (peerPlatform != kPeerPlatformAndroid) {
final scale = ((e.scale - _lastScale) * 1000).toInt();
_lastScale = e.scale;
@@ -904,6 +913,7 @@ class InputModel {
handlePointerEvent('touch', kMouseEventTypePanUpdate,
Offset(x.toDouble(), y.toDouble()));
} else {
if (isViewCamera) return;
bind.sessionSendMouse(
sessionId: sessionId,
msg: '{"type": "trackpad", "x": "$x", "y": "$y"}');
@@ -912,6 +922,7 @@ class InputModel {
}
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {
_fling = false;
return;
@@ -963,6 +974,7 @@ class InputModel {
}
void onPointerPanZoomEnd(PointerPanZoomEndEvent e) {
if (isViewCamera) return;
if (peerPlatform == kPeerPlatformAndroid) {
handlePointerEvent('touch', kMouseEventTypePanEnd, e.position);
return;
@@ -994,6 +1006,7 @@ class InputModel {
_remoteWindowCoords = [];
_windowRect = null;
if (isViewOnly) return;
if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) {
if (isPhysicalMouse.value) {
isPhysicalMouse.value = false;
@@ -1007,6 +1020,7 @@ class InputModel {
void onPointUpImage(PointerUpEvent e) {
if (isDesktop) _queryOtherWindowCoords = false;
if (isViewOnly) return;
if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
@@ -1015,6 +1029,7 @@ class InputModel {
void onPointMoveImage(PointerMoveEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) return;
if (_queryOtherWindowCoords) {
Future.delayed(Duration.zero, () async {
@@ -1049,6 +1064,7 @@ class InputModel {
void onPointerSignalImage(PointerSignalEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
if (e is PointerScrollEvent) {
var dx = e.scrollDelta.dx.toInt();
var dy = e.scrollDelta.dy.toInt();
@@ -1146,6 +1162,7 @@ class InputModel {
}
final evt = PointerEventToRust(kind, type, evtValue).toJson();
if (isViewCamera) return;
bind.sessionSendPointer(
sessionId: sessionId, msg: json.encode(modify(evt)));
}
@@ -1177,6 +1194,7 @@ class InputModel {
Offset offset, {
bool onExit = false,
}) {
if (isViewCamera) return;
double x = offset.dx;
double y = max(0.0, offset.dy);
if (_checkPeerControlProtected(x, y)) {

View File

@@ -407,7 +407,9 @@ class FfiModel with ChangeNotifier {
parent.target?.fileModel.sendEmptyDirs(evt);
}
} else if (name == "record_status") {
if (desktopType == DesktopType.remote || isMobile) {
if (desktopType == DesktopType.remote ||
desktopType == DesktopType.viewCamera ||
isMobile) {
parent.target?.recordingModel.updateStatus(evt['start'] == 'true');
}
} else {
@@ -501,7 +503,9 @@ class FfiModel with ChangeNotifier {
final display = int.parse(evt['display']);
if (_pi.currentDisplay != kAllDisplayValue) {
if (bind.peerGetDefaultSessionsCount(id: peerId) > 1) {
if (bind.peerGetSessionsCount(
id: peerId, connType: parent.target!.connType.index) >
1) {
if (display != _pi.currentDisplay) {
return;
}
@@ -809,7 +813,9 @@ class FfiModel with ChangeNotifier {
_pi.primaryDisplay = currentDisplay;
}
if (bind.peerGetDefaultSessionsCount(id: peerId) <= 1) {
if (bind.peerGetSessionsCount(
id: peerId, connType: parent.target!.connType.index) <=
1) {
_pi.currentDisplay = currentDisplay;
}
@@ -827,9 +833,11 @@ class FfiModel with ChangeNotifier {
sessionId: sessionId, arg: kOptionTouchMode) !=
'';
}
// FIXME: handle ViewCamera ConnType independently.
if (connType == ConnType.fileTransfer) {
parent.target?.fileModel.onReady();
} else if (connType == ConnType.defaultConn) {
} else if (connType == ConnType.defaultConn ||
connType == ConnType.viewCamera) {
List<Display> newDisplays = [];
List<dynamic> displays = json.decode(evt['displays']);
for (int i = 0; i < displays.length; ++i) {
@@ -859,7 +867,7 @@ class FfiModel with ChangeNotifier {
bind.sessionGetToggleOptionSync(
sessionId: sessionId, arg: kOptionToggleViewOnly));
}
if (connType == ConnType.defaultConn) {
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
final platformAdditions = evt['platform_additions'];
if (platformAdditions != null && platformAdditions != '') {
try {
@@ -2576,7 +2584,8 @@ class ElevationModel with ChangeNotifier {
onPortableServiceRunning(bool running) => _running = running;
}
enum ConnType { defaultConn, fileTransfer, portForward, rdp }
// The index values of `ConnType` are same as rust protobuf.
enum ConnType { defaultConn, fileTransfer, portForward, rdp, viewCamera }
/// Flutter state manager and data communication with the Rust core.
class FFI {
@@ -2651,10 +2660,11 @@ class FFI {
ffiModel.waitForImageTimer = null;
}
/// Start with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward].
/// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward].
void start(
String id, {
bool isFileTransfer = false,
bool isViewCamera = false,
bool isPortForward = false,
bool isRdp = false,
String? switchUuid,
@@ -2669,9 +2679,15 @@ class FFI {
closed = false;
auditNote = '';
if (isMobile) mobileReset();
assert(!(isFileTransfer && isPortForward), 'more than one connect type');
assert(
(!(isPortForward && isViewCamera)) &&
(!(isViewCamera && isPortForward)) &&
(!(isPortForward && isFileTransfer)),
'more than one connect type');
if (isFileTransfer) {
connType = ConnType.fileTransfer;
} else if (isViewCamera) {
connType = ConnType.viewCamera;
} else if (isPortForward) {
connType = ConnType.portForward;
} else {
@@ -2691,6 +2707,7 @@ class FFI {
sessionId: sessionId,
id: id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isPortForward: isPortForward,
isRdp: isRdp,
switchUuid: switchUuid ?? '',
@@ -2706,7 +2723,10 @@ class FFI {
return;
}
final addRes = bind.sessionAddExistedSync(
id: id, sessionId: sessionId, displays: Int32List.fromList(displays));
id: id,
sessionId: sessionId,
displays: Int32List.fromList(displays),
isViewCamera: isViewCamera);
if (addRes != '') {
debugPrint(
'Unreachable, failed to add existed session to $id, $addRes');
@@ -2717,6 +2737,11 @@ class FFI {
if (isDesktop && connType == ConnType.defaultConn) {
textureModel.updateCurrentDisplay(display ?? 0);
}
// FIXME: separate cameras displays or shift all indices.
if (isDesktop && connType == ConnType.viewCamera) {
// FIXME: currently the default 0 is not used.
textureModel.updateCurrentDisplay(display ?? 0);
}
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
// Though the stream is returned immediately, the stream may not be ready.
@@ -2993,6 +3018,9 @@ class PeerInfo with ChangeNotifier {
bool get isAmyuniIdd =>
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
bool get isSupportViewCamera =>
platformAdditions[kPlatformAdditionsSupportViewCamera] == true;
Display? tryGetDisplay({int? display}) {
if (displays.isEmpty) {
return null;

View File

@@ -791,6 +791,7 @@ class ServerModel with ChangeNotifier {
enum ClientType {
remote,
file,
camera,
portForward,
}
@@ -798,6 +799,7 @@ class Client {
int id = 0; // client connections inner count id
bool authorized = false;
bool isFileTransfer = false;
bool isViewCamera = false;
String portForward = "";
String name = "";
String peerId = ""; // peer user's id,show at app
@@ -815,13 +817,15 @@ class Client {
RxInt unreadChatMessageCount = 0.obs;
Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId,
Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera, this.name, this.peerId,
this.keyboard, this.clipboard, this.audio);
Client.fromJson(Map<String, dynamic> json) {
id = json['id'];
authorized = json['authorized'];
isFileTransfer = json['is_file_transfer'];
// TODO: no entry then default.
isViewCamera = json['is_view_camera'];
portForward = json['port_forward'];
name = json['name'];
peerId = json['peer_id'];
@@ -843,6 +847,7 @@ class Client {
data['id'] = id;
data['authorized'] = authorized;
data['is_file_transfer'] = isFileTransfer;
data['is_view_camera'] = isViewCamera;
data['port_forward'] = portForward;
data['name'] = name;
data['peer_id'] = peerId;
@@ -863,6 +868,8 @@ class Client {
ClientType type_() {
if (isFileTransfer) {
return ClientType.file;
} else if (isViewCamera) {
return ClientType.camera;
} else if (portForward.isNotEmpty) {
return ClientType.portForward;
} else {

View File

@@ -11,7 +11,14 @@ import 'package:flutter_hbb/models/input_model.dart';
/// must keep the order
// ignore: constant_identifier_names
enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown }
enum WindowType {
Main,
RemoteDesktop,
FileTransfer,
ViewCamera,
PortForward,
Unknown
}
extension Index on int {
WindowType get windowType {
@@ -23,6 +30,8 @@ extension Index on int {
case 2:
return WindowType.FileTransfer;
case 3:
return WindowType.ViewCamera;
case 4:
return WindowType.PortForward;
default:
return WindowType.Unknown;
@@ -50,31 +59,46 @@ class RustDeskMultiWindowManager {
final List<AsyncCallback> _windowActiveCallbacks = List.empty(growable: true);
final List<int> _remoteDesktopWindows = List.empty(growable: true);
final List<int> _fileTransferWindows = List.empty(growable: true);
final List<int> _viewCameraWindows = List.empty(growable: true);
final List<int> _portForwardWindows = List.empty(growable: true);
moveTabToNewWindow(int windowId, String peerId, String sessionId) async {
moveTabToNewWindow(int windowId, String peerId, String sessionId,
WindowType windowType) async {
var params = {
'type': WindowType.RemoteDesktop.index,
'type': windowType.index,
'id': peerId,
'tab_window_id': windowId,
'session_id': sessionId,
};
await _newSession(
false,
WindowType.RemoteDesktop,
kWindowEventNewRemoteDesktop,
peerId,
_remoteDesktopWindows,
jsonEncode(params),
);
if (windowType == WindowType.RemoteDesktop) {
await _newSession(
false,
WindowType.RemoteDesktop,
kWindowEventNewRemoteDesktop,
peerId,
_remoteDesktopWindows,
jsonEncode(params),
);
} else if (windowType == WindowType.ViewCamera) {
await _newSession(
false,
WindowType.ViewCamera,
kWindowEventNewViewCamera,
peerId,
_viewCameraWindows,
jsonEncode(params),
);
}
}
// This function must be called in the main window thread.
// Because the _remoteDesktopWindows is managed in that thread.
openMonitorSession(int windowId, String peerId, int display, int displayCount,
Rect? screenRect) async {
if (_remoteDesktopWindows.length > 1) {
for (final windowId in _remoteDesktopWindows) {
Rect? screenRect, int windowType) async {
final isCamera = windowType == WindowType.ViewCamera.index;
final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows;
if (windowIDs.length > 1) {
for (final windowId in windowIDs) {
if (await DesktopMultiWindow.invokeMethod(
windowId,
kWindowEventActiveDisplaySession,
@@ -91,7 +115,7 @@ class RustDeskMultiWindowManager {
? List.generate(displayCount, (index) => index)
: [display];
var params = {
'type': WindowType.RemoteDesktop.index,
'type': windowType,
'id': peerId,
'tab_window_id': windowId,
'display': display,
@@ -107,10 +131,10 @@ class RustDeskMultiWindowManager {
}
await _newSession(
false,
WindowType.RemoteDesktop,
kWindowEventNewRemoteDesktop,
windowType.windowType,
isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop,
peerId,
_remoteDesktopWindows,
windowIDs,
jsonEncode(params),
screenRect: screenRect,
);
@@ -277,6 +301,27 @@ class RustDeskMultiWindowManager {
);
}
Future<MultiWindowCallResult> newViewCamera(
String remoteId, {
String? password,
bool? isSharedPassword,
String? switchUuid,
bool? forceRelay,
String? connToken,
}) async {
return await newSession(
WindowType.ViewCamera,
kWindowEventNewViewCamera,
remoteId,
_viewCameraWindows,
password: password,
forceRelay: forceRelay,
switchUuid: switchUuid,
isSharedPassword: isSharedPassword,
connToken: connToken,
);
}
Future<MultiWindowCallResult> newPortForward(
String remoteId,
bool isRDP, {
@@ -324,6 +369,8 @@ class RustDeskMultiWindowManager {
return _remoteDesktopWindows;
case WindowType.FileTransfer:
return _fileTransferWindows;
case WindowType.ViewCamera:
return _viewCameraWindows;
case WindowType.PortForward:
return _portForwardWindows;
case WindowType.Unknown:
@@ -342,6 +389,9 @@ class RustDeskMultiWindowManager {
case WindowType.FileTransfer:
_fileTransferWindows.clear();
break;
case WindowType.ViewCamera:
_viewCameraWindows.clear();
break;
case WindowType.PortForward:
_portForwardWindows.clear();
break;

View File

@@ -60,7 +60,8 @@ class RustdeskImpl {
throw UnimplementedError("hostStopSystemKeyPropagate");
}
int peerGetDefaultSessionsCount({required String id, dynamic hint}) {
int peerGetSessionsCount(
{required String id, required int connType, dynamic hint}) {
return 0;
}
@@ -68,6 +69,7 @@ class RustdeskImpl {
{required String id,
required UuidValue sessionId,
required Int32List displays,
required bool isViewCamera,
dynamic hint}) {
return '';
}
@@ -76,6 +78,7 @@ class RustdeskImpl {
{required UuidValue sessionId,
required String id,
required bool isFileTransfer,
required bool isViewCamera,
required bool isPortForward,
required bool isRdp,
required String switchUuid,
@@ -90,7 +93,8 @@ class RustdeskImpl {
'id': id,
'password': password,
'is_shared_password': isSharedPassword,
'isFileTransfer': isFileTransfer
'isFileTransfer': isFileTransfer,
'isViewCamera': isViewCamera
})
]);
}

View File

@@ -10,10 +10,10 @@ import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
final testClients = [
Client(0, false, false, "UserAAAAAA", "123123123", true, false, false),
Client(1, false, false, "UserBBBBB", "221123123", true, false, false),
Client(2, false, false, "UserC", "331123123", true, false, false),
Client(3, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false)
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
];
/// flutter run -d {platform} -t test/cm_test.dart to test cm

View File

@@ -23,6 +23,7 @@ lazy_static = "1.4"
hbb_common = { path = "../hbb_common" }
webm = { git = "https://github.com/rustdesk-org/rust-webm" }
serde = {version="1.0", features=["derive"]}
nokhwa = { git = "https://github.com/rustdesk-org/nokhwa.git", branch = "fix_from_raw_parts", features = ["input-native"] }
[dependencies.winapi]
version = "0.3"

View File

@@ -0,0 +1,232 @@
use std::{
io,
sync::{Arc, Mutex},
};
use nokhwa::{
pixel_format::RgbAFormat,
query,
utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType},
Camera,
};
use hbb_common::message_proto::{DisplayInfo, Resolution};
#[cfg(feature = "vram")]
use crate::AdapterDevice;
use crate::common::{bail, ResultType};
use crate::{Frame, PixelBuffer, Pixfmt, TraitCapturer};
pub const PRIMARY_CAMERA_IDX: usize = 0;
lazy_static::lazy_static! {
static ref SYNC_CAMERA_DISPLAYS: Arc<Mutex<Vec<DisplayInfo>>> = Arc::new(Mutex::new(Vec::new()));
}
pub struct Cameras;
// pre-condition
pub fn primary_camera_exists() -> bool {
Cameras::exists(PRIMARY_CAMERA_IDX)
}
impl Cameras {
pub fn all_info() -> ResultType<Vec<DisplayInfo>> {
// TODO: support more platforms.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
return Ok(Vec::new());
match query(ApiBackend::Auto) {
Ok(cameras) => {
let mut camera_displays = SYNC_CAMERA_DISPLAYS.lock().unwrap();
camera_displays.clear();
// FIXME: nokhwa returns duplicate info for one physical camera on linux for now.
// issue: https://github.com/l1npengtul/nokhwa/issues/171
// Use only one camera as a temporary hack.
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
let Some(info) = cameras.first() else {
bail!("No camera found")
};
let camera = Self::create_camera(info.index())?;
let resolution = camera.resolution();
let (width, height) = (resolution.width() as i32, resolution.height() as i32);
camera_displays.push(DisplayInfo {
x: 0,
y: 0,
name: info.human_name().clone(),
width,
height,
online: true,
cursor_embedded: false,
scale:1.0,
original_resolution: Some(Resolution {
width,
height,
..Default::default()
}).into(),
..Default::default()
});
} else {
let mut x = 0;
for info in &cameras {
let camera = Self::create_camera(info.index())?;
let resolution = camera.resolution();
let (width, height) = (resolution.width() as i32, resolution.height() as i32);
camera_displays.push(DisplayInfo {
x,
y: 0,
name: info.human_name().clone(),
width,
height,
online: true,
cursor_embedded: false,
scale:1.0,
original_resolution: Some(Resolution {
width,
height,
..Default::default()
}).into(),
..Default::default()
});
x += width;
}
}
}
Ok(camera_displays.clone())
}
Err(e) => {
bail!("Query cameras error: {}", e)
}
}
}
pub fn exists(index: usize) -> bool {
// TODO: support more platforms.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
return false;
match query(ApiBackend::Auto) {
Ok(cameras) => index < cameras.len(),
_ => return false,
}
}
fn create_camera(index: &CameraIndex) -> ResultType<Camera> {
// TODO: support more platforms.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
bail!("This platform doesn't support camera yet");
let result = Camera::new(
index.clone(),
RequestedFormat::new::<RgbAFormat>(RequestedFormatType::AbsoluteHighestResolution),
);
match result {
Ok(camera) => Ok(camera),
Err(e) => bail!("create camera{} error: {}", index, e),
}
}
pub fn get_camera_resolution(index: usize) -> ResultType<Resolution> {
let index = CameraIndex::Index(index as u32);
let camera = Self::create_camera(&index)?;
let resolution = camera.resolution();
Ok(Resolution {
width: resolution.width() as i32,
height: resolution.height() as i32,
..Default::default()
})
}
pub fn get_sync_cameras() -> Vec<DisplayInfo> {
SYNC_CAMERA_DISPLAYS.lock().unwrap().clone()
}
pub fn get_capturer(current: usize) -> ResultType<Box<dyn TraitCapturer>> {
Ok(Box::new(CameraCapturer::new(current)?))
}
}
pub struct CameraCapturer {
camera: Camera,
data: Vec<u8>,
last_data: Vec<u8>, // for faster compare and copy
}
impl CameraCapturer {
fn new(current: usize) -> ResultType<Self> {
let index = CameraIndex::Index(current as u32);
let camera = Cameras::create_camera(&index)?;
Ok(CameraCapturer {
camera,
data: Vec::new(),
last_data: Vec::new(),
})
}
}
impl TraitCapturer for CameraCapturer {
fn frame<'a>(&'a mut self, _timeout: std::time::Duration) -> std::io::Result<Frame<'a>> {
// TODO: move this check outside `frame`.
if !self.camera.is_stream_open() {
if let Err(e) = self.camera.open_stream() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("Camera open stream error: {}", e),
));
}
}
match self.camera.frame() {
Ok(buffer) => {
match buffer.decode_image::<RgbAFormat>() {
Ok(decoded) => {
self.data = decoded.as_raw().to_vec();
crate::would_block_if_equal(&mut self.last_data, &self.data)?;
// FIXME: macos's PixelBuffer cannot be directly created from bytes slice.
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "windows"))] {
Ok(Frame::PixelBuffer(PixelBuffer::new(
&self.data,
Pixfmt::RGBA,
decoded.width() as usize,
decoded.height() as usize,
)))
} else {
Err(io::Error::new(
io::ErrorKind::Other,
format!("Camera is not supported on this platform yet"),
))
}
}
}
Err(e) => Err(io::Error::new(
io::ErrorKind::Other,
format!("Camera frame decode error: {}", e),
)),
}
}
Err(e) => Err(io::Error::new(
io::ErrorKind::Other,
format!("Camera frame error: {}", e),
)),
}
}
#[cfg(windows)]
fn is_gdi(&self) -> bool {
false
}
#[cfg(windows)]
fn set_gdi(&mut self) -> bool {
false
}
#[cfg(feature = "vram")]
fn device(&self) -> AdapterDevice {
AdapterDevice::default()
}
#[cfg(feature = "vram")]
fn set_output_texture(&mut self, _texture: bool) {}
}

View File

@@ -70,23 +70,30 @@ impl TraitCapturer for Capturer {
pub struct PixelBuffer<'a> {
data: &'a [u8],
pixfmt: Pixfmt,
width: usize,
height: usize,
stride: Vec<usize>,
}
impl<'a> PixelBuffer<'a> {
pub fn new(data: &'a [u8], width: usize, height: usize) -> Self {
pub fn new(data: &'a [u8], pixfmt: Pixfmt, width: usize, height: usize) -> Self {
let stride0 = data.len() / height;
let mut stride = Vec::new();
stride.push(stride0);
PixelBuffer {
data,
pixfmt,
width,
height,
stride,
}
}
#[allow(non_snake_case)]
pub fn with_BGRA(data: &'a [u8], width: usize, height: usize) -> Self {
Self::new(data, Pixfmt::BGRA, width, height)
}
}
impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> {
@@ -107,7 +114,7 @@ impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> {
}
fn pixfmt(&self) -> Pixfmt {
Pixfmt::BGRA
self.pixfmt
}
}
@@ -232,7 +239,7 @@ impl CapturerMag {
impl TraitCapturer for CapturerMag {
fn frame<'a>(&'a mut self, _timeout_ms: Duration) -> io::Result<Frame<'a>> {
self.inner.frame(&mut self.data)?;
Ok(Frame::PixelBuffer(PixelBuffer::new(
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
&self.data,
self.inner.get_rect().1,
self.inner.get_rect().2,

View File

@@ -48,6 +48,7 @@ pub use self::convert::*;
pub const STRIDE_ALIGN: usize = 64; // commonly used in libvpx vpx_img_alloc caller
pub const HW_STRIDE_ALIGN: usize = 0; // recommended by av_frame_get_buffer
pub mod camera;
pub mod aom;
pub mod record;
mod vpx;

View File

@@ -25,7 +25,7 @@ pub struct RecorderContext {
pub server: bool,
pub id: String,
pub dir: String,
pub display: usize,
pub video_service_name: String,
pub tx: Option<Sender<RecordState>>,
}
@@ -46,7 +46,7 @@ impl RecorderContext2 {
+ "_"
+ &ctx.id.clone()
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
+ &format!("display{}_", ctx.display)
+ &format!("{}_", ctx.video_service_name)
+ &self.format.to_string().to_lowercase()
+ if self.format == CodecFormat::VP9
|| self.format == CodecFormat::VP8

View File

@@ -5,7 +5,7 @@ use std::{
};
use crate::{
codec::{base_bitrate, enable_vram_option, EncoderApi, EncoderCfg},
codec::{enable_vram_option, EncoderApi, EncoderCfg},
hwcodec::HwCodecConfig,
AdapterDevice, CodecFormat, EncodeInput, EncodeYuvFormat, Pixfmt,
};
@@ -30,8 +30,8 @@ use hwcodec::{
// https://cybersided.com/two-monitors-two-gpus/
// https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12device-getadapterluid#remarks
lazy_static::lazy_static! {
static ref ENOCDE_NOT_USE: Arc<Mutex<HashMap<usize, bool>>> = Default::default();
static ref FALLBACK_GDI_DISPLAYS: Arc<Mutex<HashSet<usize>>> = Default::default();
static ref ENOCDE_NOT_USE: Arc<Mutex<HashMap<String, bool>>> = Default::default();
static ref FALLBACK_GDI_DISPLAYS: Arc<Mutex<HashSet<String>>> = Default::default();
}
#[derive(Debug, Clone)]
@@ -287,16 +287,25 @@ impl VRamEncoder {
crate::hwcodec::HwRamEncoder::calc_bitrate(width, height, ratio, fmt == DataFormat::H264)
}
pub fn set_not_use(display: usize, not_use: bool) {
log::info!("set display#{display} not use vram encode to {not_use}");
ENOCDE_NOT_USE.lock().unwrap().insert(display, not_use);
pub fn set_not_use(video_service_name: String, not_use: bool) {
log::info!("set {video_service_name} not use vram encode to {not_use}");
ENOCDE_NOT_USE
.lock()
.unwrap()
.insert(video_service_name, not_use);
}
pub fn set_fallback_gdi(display: usize, fallback: bool) {
pub fn set_fallback_gdi(video_service_name: String, fallback: bool) {
if fallback {
FALLBACK_GDI_DISPLAYS.lock().unwrap().insert(display);
FALLBACK_GDI_DISPLAYS
.lock()
.unwrap()
.insert(video_service_name);
} else {
FALLBACK_GDI_DISPLAYS.lock().unwrap().remove(&display);
FALLBACK_GDI_DISPLAYS
.lock()
.unwrap()
.remove(&video_service_name);
}
}
}

View File

@@ -396,7 +396,7 @@ impl Capturer {
} else {
let width = self.width;
let height = self.height;
Ok(Frame::PixelBuffer(PixelBuffer::new(
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
self.get_pixelbuffer(timeout)?,
width,
height,

View File

@@ -1389,14 +1389,14 @@ impl VideoHandler {
}
/// Start or stop screen record.
pub fn record_screen(&mut self, start: bool, id: String, display: usize) {
pub fn record_screen(&mut self, start: bool, id: String, video_service_name: String) {
self.record = false;
if start {
self.recorder = Recorder::new(RecorderContext {
server: false,
id,
dir: crate::ui_interface::video_save_directory(false),
display,
video_service_name,
tx: None,
})
.map_or(Default::default(), |r| Arc::new(Mutex::new(Some(r))));
@@ -2349,6 +2349,7 @@ impl LoginConfigHandler {
show_hidden: !self.get_option("remote_show_hidden").is_empty(),
..Default::default()
}),
ConnType::VIEW_CAMERA => lr.set_view_camera(Default::default()),
ConnType::PORT_FORWARD | ConnType::RDP => lr.set_port_forward(PortForward {
host: self.port_forward.0.clone(),
port: self.port_forward.1,
@@ -2436,6 +2437,14 @@ pub fn start_video_thread<F, T>(
{
let mut video_callback = video_callback;
let mut last_chroma = None;
let video_service_name = crate::video_service::get_service_name(
if session.is_view_camera() {
crate::video_service::VideoSource::Camera
} else {
crate::video_service::VideoSource::Monitor
},
display,
);
std::thread::spawn(move || {
#[cfg(windows)]
@@ -2478,7 +2487,7 @@ pub fn start_video_thread<F, T>(
let record_permission = session.lc.read().unwrap().record_permission;
let id = session.lc.read().unwrap().id.clone();
if record_state && record_permission {
handler.record_screen(true, id, display);
handler.record_screen(true, id, video_service_name.clone());
}
video_handler = Some(handler);
}
@@ -2559,7 +2568,7 @@ pub fn start_video_thread<F, T>(
MediaData::RecordScreen(start) => {
let id = session.lc.read().unwrap().id.clone();
if let Some(handler) = video_handler.as_mut() {
handler.record_screen(start, id, display);
handler.record_screen(start, id, video_service_name.clone());
}
}
_ => {}

View File

@@ -83,6 +83,7 @@ struct ParsedPeerInfo {
platform: String,
is_installed: bool,
idd_impl: String,
support_view_camera: bool,
}
impl ParsedPeerInfo {
@@ -129,7 +130,10 @@ impl<T: InvokeUiSession> Remote<T> {
#[cfg(target_os = "windows")]
let _file_clip_context_holder = {
// `is_port_forward()` will not reach here, but we still check it for clarity.
if !self.handler.is_file_transfer() && !self.handler.is_port_forward() {
if !self.handler.is_file_transfer()
&& !self.handler.is_port_forward()
&& !self.handler.is_view_camera()
{
// It is ok to call this function multiple times.
ContextSend::enable(true);
Some(crate::SimpleCallOnReturn {
@@ -152,6 +156,8 @@ impl<T: InvokeUiSession> Remote<T> {
let mut received = false;
let conn_type = if self.handler.is_file_transfer() {
ConnType::FILE_TRANSFER
} else if self.handler.is_view_camera() {
ConnType::VIEW_CAMERA
} else {
ConnType::default()
};
@@ -173,7 +179,7 @@ impl<T: InvokeUiSession> Remote<T> {
.set_connected();
self.handler.set_connection_type(peer.is_secured(), direct); // flutter -> connection_ready
self.handler.update_direct(Some(direct));
if conn_type == ConnType::DEFAULT_CONN {
if conn_type == ConnType::DEFAULT_CONN || conn_type == ConnType::VIEW_CAMERA {
self.handler
.set_fingerprint(crate::common::pk_to_fingerprint(pk.unwrap_or_default()));
}
@@ -190,7 +196,8 @@ impl<T: InvokeUiSession> Remote<T> {
{
let is_conn_not_default = self.handler.is_file_transfer()
|| self.handler.is_port_forward()
|| self.handler.is_rdp();
|| self.handler.is_rdp()
|| self.handler.is_view_camera();
if !is_conn_not_default {
(self.client_conn_id, rx_clip_client_holder.0) =
clipboard::get_rx_cliprdr_client(&self.handler.get_id());
@@ -330,12 +337,12 @@ impl<T: InvokeUiSession> Remote<T> {
.set_disconnected(round);
#[cfg(not(target_os = "ios"))]
if _set_disconnected_ok {
if !self.handler.is_view_camera() && _set_disconnected_ok {
Client::try_stop_clipboard();
}
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
if _set_disconnected_ok {
if !self.handler.is_view_camera() && _set_disconnected_ok {
crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id);
}
}
@@ -1176,6 +1183,25 @@ impl<T: InvokeUiSession> Remote<T> {
}
}
fn check_view_camera_support(&self, peer_version: &str, peer_platform: &str) -> bool {
if self.peer_info.support_view_camera {
return true;
}
if hbb_common::get_version_number(&peer_version) < hbb_common::get_version_number("1.3.9")
&& (peer_platform == "Windows" || peer_platform == "Linux")
{
self.handler.msgbox(
"error",
"Download new version",
"upgrade_remote_rustdesk_client_to_{1.3.9}_tip",
"",
);
} else {
self.handler.on_error("view_camera_unsupported_tip");
}
return false;
}
async fn handle_msg_from_peer(&mut self, data: &[u8], peer: &mut Stream) -> bool {
if let Ok(msg_in) = Message::parse_from_bytes(&data) {
match msg_in.union {
@@ -1230,10 +1256,19 @@ impl<T: InvokeUiSession> Remote<T> {
let peer_version = pi.version.clone();
let peer_platform = pi.platform.clone();
self.set_peer_info(&pi);
if self.handler.is_view_camera() {
if !self.check_view_camera_support(&peer_version, &peer_platform) {
self.handler.lc.write().unwrap().handle_peer_info(&pi);
return false;
}
}
self.handler.handle_peer_info(pi);
#[cfg(all(target_os = "windows", not(feature = "flutter")))]
self.check_clipboard_file_context();
if !(self.handler.is_file_transfer() || self.handler.is_port_forward()) {
if !(self.handler.is_file_transfer()
|| self.handler.is_port_forward()
|| self.handler.is_view_camera())
{
#[cfg(feature = "flutter")]
#[cfg(not(target_os = "ios"))]
let rx = Client::try_start_clipboard(None);
@@ -1532,6 +1567,9 @@ impl<T: InvokeUiSession> Remote<T> {
);
}
}
Ok(Permission::Camera) => {
self.handler.set_permission("camera", p.enabled);
}
Ok(Permission::Restart) => {
self.handler.set_permission("restart", p.enabled);
}
@@ -1773,6 +1811,11 @@ impl<T: InvokeUiSession> Remote<T> {
.flatten()
.unwrap_or_default()
.to_string();
self.peer_info.support_view_camera = platform_additions
.get("support_view_camera")
.map(|v| v.as_bool())
.flatten()
.unwrap_or(false);
}
}

View File

@@ -53,6 +53,7 @@ pub fn core_main() -> Option<Vec<String>> {
"--connect",
"--play",
"--file-transfer",
"--view-camera",
"--port-forward",
"--rdp",
]
@@ -99,7 +100,7 @@ pub fn core_main() -> Option<Vec<String>> {
}
}
#[cfg(windows)]
if args.contains(&"--connect".to_string()) {
if args.contains(&"--connect".to_string()) || args.contains(&"--view-camera".to_string()) {
hbb_common::platform::windows::start_cpu_performance_monitor();
}
#[cfg(feature = "flutter")]
@@ -589,7 +590,7 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option<Vec<Strin
let mut param_array = vec![];
while let Some(arg) = args.next() {
match arg.as_str() {
"--connect" | "--play" | "--file-transfer" | "--port-forward" | "--rdp" => {
"--connect" | "--play" | "--file-transfer" | "--view-camera" | "--port-forward" | "--rdp" => {
authority = Some((&arg.to_string()[2..]).to_owned());
id = args.next();
}

View File

@@ -1149,8 +1149,14 @@ pub fn session_add_existed(
peer_id: String,
session_id: SessionID,
displays: Vec<i32>,
is_view_camera: bool,
) -> ResultType<()> {
sessions::insert_peer_session_id(peer_id, ConnType::DEFAULT_CONN, session_id, displays);
let conn_type = if is_view_camera {
ConnType::VIEW_CAMERA
} else {
ConnType::DEFAULT_CONN
};
sessions::insert_peer_session_id(peer_id, conn_type, session_id, displays);
Ok(())
}
@@ -1160,11 +1166,13 @@ pub fn session_add_existed(
///
/// * `id` - The identifier of the remote session with prefix. Regex: [\w]*[\_]*[\d]+
/// * `is_file_transfer` - If the session is used for file transfer.
/// * `is_view_camera` - If the session is used for view camera.
/// * `is_port_forward` - If the session is used for port forward.
pub fn session_add(
session_id: &SessionID,
id: &str,
is_file_transfer: bool,
is_view_camera: bool,
is_port_forward: bool,
is_rdp: bool,
switch_uuid: &str,
@@ -1175,6 +1183,8 @@ pub fn session_add(
) -> ResultType<FlutterSession> {
let conn_type = if is_file_transfer {
ConnType::FILE_TRANSFER
} else if is_view_camera {
ConnType::VIEW_CAMERA
} else if is_port_forward {
if is_rdp {
ConnType::RDP

View File

@@ -92,16 +92,28 @@ pub fn host_stop_system_key_propagate(_stopped: bool) {
}
// This function is only used to count the number of control sessions.
pub fn peer_get_default_sessions_count(id: String) -> SyncReturn<usize> {
SyncReturn(sessions::get_session_count(id, ConnType::DEFAULT_CONN))
pub fn peer_get_sessions_count(id: String, conn_type: i32) -> SyncReturn<usize> {
let conn_type = if conn_type == ConnType::VIEW_CAMERA as i32 {
ConnType::VIEW_CAMERA
} else if conn_type == ConnType::FILE_TRANSFER as i32 {
ConnType::FILE_TRANSFER
} else if conn_type == ConnType::PORT_FORWARD as i32 {
ConnType::PORT_FORWARD
} else if conn_type == ConnType::RDP as i32 {
ConnType::RDP
} else {
ConnType::DEFAULT_CONN
};
SyncReturn(sessions::get_session_count(id, conn_type))
}
pub fn session_add_existed_sync(
id: String,
session_id: SessionID,
displays: Vec<i32>,
is_view_camera: bool,
) -> SyncReturn<String> {
if let Err(e) = session_add_existed(id.clone(), session_id, displays) {
if let Err(e) = session_add_existed(id.clone(), session_id, displays, is_view_camera) {
SyncReturn(format!("Failed to add session with id {}, {}", &id, e))
} else {
SyncReturn("".to_owned())
@@ -112,6 +124,7 @@ pub fn session_add_sync(
session_id: SessionID,
id: String,
is_file_transfer: bool,
is_view_camera: bool,
is_port_forward: bool,
is_rdp: bool,
switch_uuid: String,
@@ -124,6 +137,7 @@ pub fn session_add_sync(
&session_id,
&id,
is_file_transfer,
is_view_camera,
is_port_forward,
is_rdp,
&switch_uuid,

View File

@@ -188,6 +188,7 @@ pub enum Data {
Login {
id: i32,
is_file_transfer: bool,
is_view_camera: bool,
peer_id: String,
name: String,
authorized: bool,
@@ -1280,6 +1281,6 @@ mod test {
#[test]
fn verify_ffi_enum_data_size() {
println!("{}", std::mem::size_of::<Data>());
assert!(std::mem::size_of::<Data>() < 96);
assert!(std::mem::size_of::<Data>() <= 96);
}
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "عرض الكاميرا"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Прагляд камеры"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Калі ласка, абнавіце кліент RustDesk да версіі {} або навейшай на аддаленым баку!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Преглед на камерата"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Моля, надстройте клиента RustDesk до версия {} или по-нова от отдалечената страна!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Sense etiquetar"),
("new-version-of-{}-tip", ""),
("Accessible devices", "Dispositius accessibles"),
("View camera", "Mostra la càmera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Veuillez mettre à niveau le client RustDesk vers la version {} ou plus récente du côté distant !"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "无标签"),
("new-version-of-{}-tip", "{} 版本更新"),
("Accessible devices", "可访问的设备"),
("View camera", "查看摄像头"),
("upgrade_remote_rustdesk_client_to_{}_tip", "请在远程端将 RustDesk 客户端升级至版本 {} 或更新版本!"),
("view_camera_unsupported_tip", "您的远程端不支持查看摄像头。"),
("Enable camera", "允许查看摄像头"),
("No cameras", "没有摄像头"),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Zobrazit kameru"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Upgradujte prosím klienta RustDesk na verzi {} nebo novější na vzdálené straně!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Se kamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Opgrader venligst RustDesk-klienten til version {} eller nyere på fjernsiden!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Unmarkiert"),
("new-version-of-{}-tip", "Es ist eine neue Version von {} verfügbar"),
("Accessible devices", "Erreichbare Geräte"),
("View camera", "Kamera anzeigen"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Bitte aktualisieren Sie den RustDesk-Client auf der Remote-Seite auf Version {} oder neuer!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Χωρίς ετικέτα"),
("new-version-of-{}-tip", "Υπάρχει διαθέσιμη νέα έκδοση του {}"),
("Accessible devices", "Προσβάσιμες συσκευές"),
("View camera", "Προβολή κάμερας"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Αναβαθμίστε τον πελάτη RustDesk στην έκδοση {} ή νεότερη στην απομακρυσμένη πλευρά!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -237,5 +237,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("one-way-file-transfer-tip", "One-way file transfer is enabled on the controlled side."),
("web_id_input_tip", "You can input an ID in the same server, direct IP access is not supported in web client.\nIf you want to access a device on another server, please append the server address (<id>@<server_address>?key=<key_value>), for example,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nIf you want to access a device on a public server, please input \"<id>@public\", the key is not needed for public server."),
("new-version-of-{}-tip", "There is a new version of {} available"),
("View camera", "View camera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Please upgrade the RustDesk client to version {} or newer on the remote side!"),
("view_camera_unsupported_tip", "The remote device does not support viewing the camera."),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Rigardi kameron"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Sin itiquetar"),
("new-version-of-{}-tip", "Hay una nueva versión de {} disponible"),
("Accessible devices", ""),
("View camera", "Ver cámara"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Por favor, actualiza el cliente RustDesk a la versión {} o superior en el lado remoto"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -600,7 +600,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Everyone", "Igaüks"),
("ab_web_console_tip", "Rohkem leiad veebikonsoolist"),
("allow-only-conn-window-open-tip", "Luba ühendus ainult siis, kui RustDeski aken on avatud."),
("no_need_privacy_mode_no_physical_displays_tip", "Füüsilisi ekraane pole, privaatsusrežiimi kasutamine pole vajalik."),
("no_need_privacy_mode_no_physical_displays_tip", "Füüsilisi ekraane pole, privaatsusrežiimi kasutamine pole vajalik."),
("Follow remote cursor", "Jälgi kaugkursorit"),
("Follow remote window focus", "Jälgi kaugakna fookust"),
("default_proxy_tip", "Vaikimisi protokoll ja port on Socks5 ja 1080."),
@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Sildistamata"),
("new-version-of-{}-tip", "Saadaval on {} uus versioon"),
("Accessible devices", "Ligipääsetavad seadmed"),
("View camera", "Vaata kaamerat"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Täiendage RustDeski klient kaugküljel versioonile {} või uuemale!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Ikusi kamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Mesedez, eguneratu RustDesk bezeroa {} bertsiora edo berriagoa urruneko aldean!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "نمایش دوربین"),
("upgrade_remote_rustdesk_client_to_{}_tip", "لطفاً مشتری RustDesk را به نسخه {} یا جدیدتر در سمت راه دور ارتقا دهید!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Sans étiquette"),
("new-version-of-{}-tip", "Une nouvelle version de {} est disponible"),
("Accessible devices", "Appareils accessibles"),
("View camera", "Voir la caméra"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Veuillez mettre à jour le client RustDesk avec la version {} ou une version plus récente sur l'appareil distant"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", ""),
("upgrade_remote_rustdesk_client_to_{}_tip", "אנא שדרג את לקוח RustDesk לגרסה {} או חדשה יותר בצד המרוחק!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Pregled kamere"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Molimo ažurirajte RustDesk klijent na verziju {} ili noviju na udaljenoj strani!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Címkézetlen"),
("new-version-of-{}-tip", "A(z) {} új verziója"),
("Accessible devices", "Hozzáférhető eszközök"),
("View camera", "Kamera megtekintése"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Kérjük, frissítse a RustDesk kliens {} vagy újabb verziójára a távoli oldalon!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Lihat Kamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Silakan perbarui klien RustDesk ke versi {} atau lebih baru di sisi remote!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Senza tag"),
("new-version-of-{}-tip", "È disponibile una nuova versione di {}"),
("Accessible devices", "Dispositivi accessibili"),
("View camera", "Visualizza telecamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Aggiorna il client RustDesk remoto alla versione {} o successiva!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "カメラを表示"),
("upgrade_remote_rustdesk_client_to_{}_tip", "リモート側のRustDeskクライアントをバージョン{}以上にアップグレードしてください!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "태그 없음"),
("new-version-of-{}-tip", "{} 의 새로운 버전이 출시되었습니다."),
("Accessible devices", ""),
("View camera", "카메라 보기"),
("upgrade_remote_rustdesk_client_to_{}_tip", "원격 측의 RustDesk 클라이언트를 {} 버전 이상으로 업그레이드하십시오!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Камераны Көру"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Қашықтағы жақтағы RustDesk клиентін {} немесе одан жоғары нұсқаға жаңартуды өтінеміз!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Peržiūrėti kamerą"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Prašome atnaujinti nuotolinės pusės RustDesk klientą į {} ar naujesnę versiją!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Neatzīmēts"),
("new-version-of-{}-tip", "Ir pieejama jauna {} versija"),
("Accessible devices", "Pieejamas ierīces"),
("View camera", "Skatīt kameru"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Lūdzu, jauniniet attālās puses RustDesk klientu uz versiju {} vai jaunāku!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Vis kamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Ongemarkeerd"),
("new-version-of-{}-tip", "Er is een nieuwe versie van {} beschikbaar"),
("Accessible devices", "Toegankelijke apparaten"),
("View camera", "Camera bekijken"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Upgrade de RustDesk client naar versie {} of nieuwer op de externe computer!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Bez etykiety"),
("new-version-of-{}-tip", "Dostępna jest nowa wersja {}"),
("Accessible devices", "Dostępne urządzenia"),
("View camera", "Podgląd kamery"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Proszę zaktualizować zdalny klient RustDesk do wersji {} lub nowszej!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Ver câmara"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Visualizar Câmera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Atualize o cliente RustDesk para a versão {} ou superior no lado remoto."),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Vezi camera"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Без метки"),
("new-version-of-{}-tip", "Доступна новая версия {}"),
("Accessible devices", "Доступные устройства"),
("View camera", "Просмотр камеры"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Обновите клиент RustDesk до версии {} или новее на удаленной стороне!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Chene tag"),
("new-version-of-{}-tip", "B'at una versione noa de {} a disponimentu"),
("Accessible devices", "Dispositivos atzessìbiles"),
("View camera", "Mustra càmera"),
("upgrade_remote_rustdesk_client_to_{}_tip", "¡Actualice el cliente RustDesk a la versión {} o más reciente en el lado remoto!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Zobraziť kameru"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Aktualizujte klienta RustDesk na verziu {} alebo novšiu na vzdialenej strane!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Neoznačeno"),
("new-version-of-{}-tip", "Na voljo je nova različica {}"),
("Accessible devices", ""),
("View camera", "Pogled kamere"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Prosimo, nadgradite RustDesk odjemalec na različico {} ali novejšo na oddaljeni strani."),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", ""),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Pregled kamere"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Visa kamera"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", ""),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "ดูกล้อง"),
("upgrade_remote_rustdesk_client_to_{}_tip", "กรุณาอัปเดต RustDesk ไคลเอนต์ไปยังเวอร์ชัน {} หรือใหม่กว่าที่ฝั่งปลายทาง!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Kamerayı görüntüle"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "無標籤"),
("new-version-of-{}-tip", "有新版本的 {} 可用"),
("Accessible devices", "可存取的裝置"),
("View camera", "檢視相機"),
("upgrade_remote_rustdesk_client_to_{}_tip", "請將遠端 RustDesk 用戶端升級到 {} 或更新版本!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", "Без міток"),
("new-version-of-{}-tip", "Доступна нова версія {}"),
("Accessible devices", ""),
("View camera", "Перегляд камери"),
("upgrade_remote_rustdesk_client_to_{}_tip", "Будь ласка, оновіть RustDesk клієнт на віддаленому пристрої до версії {} чи новіше!"),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -657,5 +657,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Untagged", ""),
("new-version-of-{}-tip", ""),
("Accessible devices", ""),
("View camera", "Xem camera"),
("upgrade_remote_rustdesk_client_to_{}_tip", ""),
("view_camera_unsupported_tip", ""),
("Enable camera", ""),
("No cameras", ""),
].iter().cloned().collect();
}

View File

@@ -24,9 +24,11 @@ use hbb_common::{
sodiumoxide::crypto::{box_, sign},
timeout, tokio, ResultType, Stream,
};
use scrap::camera;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use service::ServiceTmpl;
use service::{EmptyExtraFieldService, GenericService, Service, Subscriber};
use video_service::VideoSource;
use crate::ipc::Data;
@@ -76,7 +78,6 @@ const CONFIG_SYNC_INTERVAL_SECS: f32 = 0.3;
lazy_static::lazy_static! {
pub static ref CHILD_PROCESS: Childs = Default::default();
pub static ref CONN_COUNT: Arc<Mutex<usize>> = Default::default();
// A client server used to provide local services(audio, video, clipboard, etc.)
// for all initiative connections.
//
@@ -279,22 +280,53 @@ async fn create_relay_connection_(
impl Server {
fn is_video_service_name(name: &str) -> bool {
name.starts_with(video_service::NAME)
name.starts_with(VideoSource::Monitor.service_name_prefix())
|| name.starts_with(VideoSource::Camera.service_name_prefix())
}
pub fn try_add_primary_camera_service(&mut self) {
if !camera::primary_camera_exists() {
return;
}
let primary_camera_name =
video_service::get_service_name(VideoSource::Camera, camera::PRIMARY_CAMERA_IDX);
if !self.contains(&primary_camera_name) {
self.add_service(Box::new(video_service::new(
VideoSource::Camera,
camera::PRIMARY_CAMERA_IDX,
)));
}
}
pub fn try_add_primay_video_service(&mut self) {
let primary_video_service_name =
video_service::get_service_name(*display_service::PRIMARY_DISPLAY_IDX);
let primary_video_service_name = video_service::get_service_name(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
);
if !self.contains(&primary_video_service_name) {
self.add_service(Box::new(video_service::new(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
)));
}
}
pub fn add_camera_connection(&mut self, conn: ConnInner) {
if camera::primary_camera_exists() {
let primary_camera_name =
video_service::get_service_name(VideoSource::Camera, camera::PRIMARY_CAMERA_IDX);
if let Some(s) = self.services.get(&primary_camera_name) {
s.on_subscribe(conn.clone());
}
}
self.connections.insert(conn.id(), conn);
}
pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) {
let primary_video_service_name =
video_service::get_service_name(*display_service::PRIMARY_DISPLAY_IDX);
let primary_video_service_name = video_service::get_service_name(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
);
for s in self.services.values() {
let name = s.name();
if Self::is_video_service_name(&name) && name != primary_video_service_name {
@@ -307,7 +339,6 @@ impl Server {
#[cfg(target_os = "macos")]
self.update_enable_retina();
self.connections.insert(conn.id(), conn);
*CONN_COUNT.lock().unwrap() = self.connections.len();
}
pub fn remove_connection(&mut self, conn: &ConnInner) {
@@ -315,7 +346,6 @@ impl Server {
s.on_unsubscribe(conn.id());
}
self.connections.remove(&conn.id());
*CONN_COUNT.lock().unwrap() = self.connections.len();
#[cfg(target_os = "macos")]
self.update_enable_retina();
}
@@ -361,10 +391,15 @@ impl Server {
self.id_count
}
pub fn set_video_service_opt(&self, display: Option<usize>, opt: &str, value: &str) {
pub fn set_video_service_opt(
&self,
display: Option<(VideoSource, usize)>,
opt: &str,
value: &str,
) {
for (k, v) in self.services.iter() {
if let Some(display) = display {
if k != &video_service::get_service_name(display) {
if let Some((source, display)) = display {
if k != &video_service::get_service_name(source, display) {
continue;
}
}
@@ -392,13 +427,14 @@ impl Server {
fn capture_displays(
&mut self,
conn: ConnInner,
source: VideoSource,
displays: &[usize],
include: bool,
exclude: bool,
) {
let displays = displays
.iter()
.map(|d| video_service::get_service_name(*d))
.map(|d| video_service::get_service_name(source, *d))
.collect::<Vec<_>>();
let keys = self.services.keys().cloned().collect::<Vec<_>>();
for name in keys.iter() {

View File

@@ -44,6 +44,7 @@ use hbb_common::{
};
#[cfg(any(target_os = "android", target_os = "ios"))]
use scrap::android::{call_main_service_key_event, call_main_service_pointer_input};
use scrap::camera;
use serde_derive::Serialize;
use serde_json::{json, value::Value};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -167,6 +168,7 @@ pub enum AuthConnType {
Remote,
FileTransfer,
PortForward,
ViewCamera,
}
pub struct Connection {
@@ -179,6 +181,7 @@ pub struct Connection {
timer: crate::RustDeskInterval,
file_timer: crate::RustDeskInterval,
file_transfer: Option<(String, bool)>,
view_camera: bool,
port_forward_socket: Option<Framed<TcpStream, BytesCodec>>,
port_forward_address: String,
tx_to_cm: mpsc::UnboundedSender<ipc::Data>,
@@ -222,6 +225,7 @@ pub struct Connection {
portable: PortableState,
from_switch: bool,
voice_call_request_timestamp: Option<NonZeroI64>,
voice_calling: bool,
options_in_login: Option<OptionMessage>,
#[cfg(not(any(target_os = "ios")))]
pressed_modifiers: HashSet<rdev::Key>,
@@ -331,6 +335,7 @@ impl Connection {
timer: crate::rustdesk_interval(time::interval(SEC30)),
file_timer: crate::rustdesk_interval(time::interval(SEC30)),
file_transfer: None,
view_camera: false,
port_forward_socket: None,
port_forward_address: "".to_owned(),
tx_to_cm,
@@ -369,6 +374,7 @@ impl Connection {
from_switch: false,
audio_sender: None,
voice_call_request_timestamp: None,
voice_calling: false,
options_in_login: None,
#[cfg(not(any(target_os = "ios")))]
pressed_modifiers: Default::default(),
@@ -533,9 +539,17 @@ impl Connection {
conn.send_permission(Permission::Audio, enabled).await;
if conn.authorized {
if let Some(s) = conn.server.upgrade() {
s.write().unwrap().subscribe(
super::audio_service::NAME,
conn.inner.clone(), conn.audio_enabled());
if conn.is_authed_view_camera_conn() {
if conn.voice_calling || !conn.audio_enabled() {
s.write().unwrap().subscribe(
super::audio_service::NAME,
conn.inner.clone(), conn.audio_enabled());
}
} else {
s.write().unwrap().subscribe(
super::audio_service::NAME,
conn.inner.clone(), conn.audio_enabled());
}
}
}
} else if &name == "file" {
@@ -774,7 +788,7 @@ impl Connection {
});
conn.send(msg_out.into()).await;
}
if conn.is_authed_remote_conn() {
if conn.is_authed_remote_conn() || conn.view_camera {
if let Some(last_test_delay) = conn.last_test_delay {
video_service::VIDEO_QOS.lock().unwrap().user_delay_response_elapsed(id, last_test_delay.elapsed().as_millis());
}
@@ -1189,6 +1203,8 @@ impl Connection {
(1, AuthConnType::FileTransfer)
} else if self.port_forward_socket.is_some() {
(2, AuthConnType::PortForward)
} else if self.view_camera {
(3, AuthConnType::ViewCamera)
} else {
(0, AuthConnType::Remote)
};
@@ -1277,6 +1293,11 @@ impl Connection {
platform_additions.insert("has_file_clipboard".into(), json!(has_file_clipboard));
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
platform_additions.insert("support_view_camera".into(), json!(true));
}
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
if !platform_additions.is_empty() {
pi.platform_additions = serde_json::to_string(&platform_additions).unwrap_or("".into());
@@ -1290,7 +1311,8 @@ impl Connection {
return;
}
#[cfg(target_os = "linux")]
if !self.file_transfer.is_some() && !self.port_forward_socket.is_some() {
if !self.file_transfer.is_some() && !self.port_forward_socket.is_some() && !self.view_camera
{
let mut msg = "".to_string();
if crate::platform::linux::is_login_screen_wayland() {
msg = crate::client::LOGIN_SCREEN_WAYLAND.to_owned()
@@ -1347,6 +1369,29 @@ impl Connection {
self.handle_windows_specific_session(&mut pi, &mut wait_session_id_confirm);
if self.file_transfer.is_some() {
res.set_peer_info(pi);
} else if self.view_camera {
let supported_encoding = scrap::codec::Encoder::supported_encoding();
self.last_supported_encoding = Some(supported_encoding.clone());
log::info!("peer info supported_encoding: {:?}", supported_encoding);
pi.encoding = Some(supported_encoding).into();
pi.displays = camera::Cameras::all_info().unwrap_or(Vec::new());
pi.current_display = camera::PRIMARY_CAMERA_IDX as _;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
pi.resolutions = Some(SupportedResolutions {
resolutions: camera::Cameras::get_camera_resolution(
pi.current_display as usize,
)
.ok()
.into_iter()
.collect(),
..Default::default()
})
.into();
}
res.set_peer_info(pi);
self.update_codec_on_login();
} else {
let supported_encoding = scrap::codec::Encoder::supported_encoding();
self.last_supported_encoding = Some(supported_encoding.clone());
@@ -1414,15 +1459,31 @@ impl Connection {
} else {
self.delayed_read_dir = Some((dir.to_owned(), show_hidden));
}
} else if self.view_camera {
if !wait_session_id_confirm {
self.try_sub_camera_displays();
}
self.keyboard = false;
self.send_permission(Permission::Keyboard, false).await;
} else if sub_service {
if !wait_session_id_confirm {
self.try_sub_services();
self.try_sub_monitor_services();
}
}
}
fn try_sub_services(&mut self) {
let is_remote = self.file_transfer.is_none() && self.port_forward_socket.is_none();
fn try_sub_camera_displays(&mut self) {
if let Some(s) = self.server.upgrade() {
let mut s = s.write().unwrap();
s.try_add_primary_camera_service();
s.add_camera_connection(self.inner.clone());
}
}
fn try_sub_monitor_services(&mut self) {
let is_remote =
self.file_transfer.is_none() && self.port_forward_socket.is_none() && !self.view_camera;
if is_remote && !self.services_subed {
self.services_subed = true;
if let Some(s) = self.server.upgrade() {
@@ -1466,7 +1527,7 @@ impl Connection {
if let Some(current_sid) = crate::platform::get_current_process_session_id() {
if crate::platform::is_installed()
&& crate::platform::is_share_rdp()
&& raii::AuthedConnID::remote_and_file_conn_count() == 1
&& raii::AuthedConnID::non_port_forward_conn_count() == 1
&& sessions.len() > 1
&& sessions.iter().any(|e| e.sid == current_sid)
&& get_version_number(&self.lr.version) >= get_version_number("1.2.4")
@@ -1539,6 +1600,7 @@ impl Connection {
self.send_to_cm(ipc::Data::Login {
id: self.inner.id(),
is_file_transfer: self.file_transfer.is_some(),
is_view_camera: self.view_camera,
port_forward: self.port_forward_address.clone(),
peer_id,
name,
@@ -1781,6 +1843,15 @@ impl Connection {
}
self.file_transfer = Some((ft.dir, ft.show_hidden));
}
Some(login_request::Union::ViewCamera(_vc)) => {
if !Connection::permission(keys::OPTION_ENABLE_CAMERA) {
self.send_login_error("No permission of viewing camera")
.await;
sleep(1.).await;
return false;
}
self.view_camera = true;
}
Some(login_request::Union::PortForward(mut pf)) => {
if !Connection::permission("enable-tunnel") {
self.send_login_error("No permission of IP tunneling").await;
@@ -1987,6 +2058,9 @@ impl Connection {
match msg.union {
#[allow(unused_mut)]
Some(message::Union::MouseEvent(mut me)) => {
if self.is_authed_view_camera_conn() {
return true;
}
#[cfg(any(target_os = "android", target_os = "ios"))]
if let Err(e) = call_main_service_pointer_input("mouse", me.mask, me.x, me.y) {
log::debug!("call_main_service_pointer_input fail:{}", e);
@@ -2005,6 +2079,9 @@ impl Connection {
self.update_auto_disconnect_timer();
}
Some(message::Union::PointerDeviceEvent(pde)) => {
if self.is_authed_view_camera_conn() {
return true;
}
#[cfg(any(target_os = "android", target_os = "ios"))]
if let Err(e) = match pde.union {
Some(pointer_device_event::Union::TouchEvent(touch)) => match touch.union {
@@ -2044,6 +2121,9 @@ impl Connection {
Some(message::Union::KeyEvent(..)) => {}
#[cfg(any(target_os = "android"))]
Some(message::Union::KeyEvent(mut me)) => {
if self.is_authed_view_camera_conn() {
return true;
}
let key = match me.mode.enum_value() {
Ok(KeyboardMode::Map) => {
Some(crate::keyboard::keycode_to_rdev_key(me.chr()))
@@ -2096,6 +2176,9 @@ impl Connection {
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Some(message::Union::KeyEvent(me)) => {
if self.is_authed_view_camera_conn() {
return true;
}
if self.peer_keyboard_enabled() {
if is_enter(&me) {
CLICK_TIME.store(get_time(), Ordering::SeqCst);
@@ -2592,7 +2675,7 @@ impl Connection {
let sessions = crate::platform::get_available_sessions(false);
if crate::platform::is_installed()
&& crate::platform::is_share_rdp()
&& raii::AuthedConnID::remote_and_file_conn_count() == 1
&& raii::AuthedConnID::non_port_forward_conn_count() == 1
&& sessions.len() > 1
&& current_process_sid != sid
&& sessions.iter().any(|e| e.sid == sid)
@@ -2606,15 +2689,19 @@ impl Connection {
if let Some((dir, show_hidden)) = self.delayed_read_dir.take() {
self.read_dir(&dir, show_hidden);
}
} else if self.view_camera {
self.try_sub_camera_displays();
} else {
self.try_sub_services();
self.try_sub_monitor_services();
}
}
}
Some(misc::Union::MessageQuery(mq)) => {
if let Some(msg_out) =
video_service::make_display_changed_msg(mq.switch_display as _, None)
{
if let Some(msg_out) = video_service::make_display_changed_msg(
mq.switch_display as _,
None,
self.video_source(),
) {
self.send(msg_out).await;
}
}
@@ -2713,7 +2800,7 @@ impl Connection {
video_service::refresh();
self.server.upgrade().map(|s| {
s.read().unwrap().set_video_service_opt(
display,
display.map(|d| (self.video_source(), d)),
video_service::OPTION_REFRESH,
super::service::SERVICE_OPTION_VALUE_TRUE,
);
@@ -2743,19 +2830,33 @@ impl Connection {
// 1. For compatibility with old versions ( < 1.2.4 ).
// 2. Sciter version.
// 3. Update `SupportedResolutions`.
if let Some(msg_out) = video_service::make_display_changed_msg(self.display_idx, None) {
if let Some(msg_out) =
video_service::make_display_changed_msg(self.display_idx, None, self.video_source())
{
self.send(msg_out).await;
}
}
}
fn video_source(&self) -> VideoSource {
if self.view_camera {
VideoSource::Camera
} else {
VideoSource::Monitor
}
}
fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) {
let new_service_name = video_service::get_service_name(display_idx);
let old_service_name = video_service::get_service_name(self.display_idx);
let new_service_name = video_service::get_service_name(self.video_source(), display_idx);
let old_service_name =
video_service::get_service_name(self.video_source(), self.display_idx);
let mut lock = server.write().unwrap();
if display_idx != *display_service::PRIMARY_DISPLAY_IDX {
if !lock.contains(&new_service_name) {
lock.add_service(Box::new(video_service::new(display_idx)));
lock.add_service(Box::new(video_service::new(
self.video_source(),
display_idx,
)));
}
}
// For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately.
@@ -2790,26 +2891,27 @@ impl Connection {
}
async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) {
let video_source = self.video_source();
if let Some(sever) = self.server.upgrade() {
let mut lock = sever.write().unwrap();
for display in add.iter() {
let service_name = video_service::get_service_name(*display);
let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(*display)));
lock.add_service(Box::new(video_service::new(video_source, *display)));
}
}
for display in set.iter() {
let service_name = video_service::get_service_name(*display);
let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(*display)));
lock.add_service(Box::new(video_service::new(video_source, *display)));
}
}
if !add.is_empty() {
lock.capture_displays(self.inner.clone(), add, true, false);
lock.capture_displays(self.inner.clone(), video_source, add, true, false);
} else if !sub.is_empty() {
lock.capture_displays(self.inner.clone(), sub, false, true);
lock.capture_displays(self.inner.clone(), video_source, sub, false, true);
} else {
lock.capture_displays(self.inner.clone(), set, true, true);
lock.capture_displays(self.inner.clone(), video_source, set, true, true);
}
self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1;
if self.follow_remote_window {
@@ -2931,6 +3033,16 @@ impl Connection {
self.send_to_cm(Data::CloseVoiceCall("".to_owned()));
}
self.send(msg).await;
self.voice_calling = accepted;
if self.is_authed_view_camera_conn() {
if let Some(s) = self.server.upgrade() {
s.write().unwrap().subscribe(
super::audio_service::NAME,
self.inner.clone(),
self.audio_enabled() && accepted,
);
}
}
} else {
log::warn!("Possible a voice call attack.");
}
@@ -2940,6 +3052,14 @@ impl Connection {
crate::audio_service::set_voice_call_input_device(None, true);
// Notify the connection manager that the voice call has been closed.
self.send_to_cm(Data::CloseVoiceCall("".to_owned()));
self.voice_calling = false;
if self.is_authed_view_camera_conn() {
if let Some(s) = self.server.upgrade() {
s.write()
.unwrap()
.subscribe(super::audio_service::NAME, self.inner.clone(), false);
}
}
}
async fn update_options(&mut self, o: &OptionMessage) {
@@ -3016,11 +3136,21 @@ impl Connection {
if q != BoolOption::NotSet {
self.disable_audio = q == BoolOption::Yes;
if let Some(s) = self.server.upgrade() {
s.write().unwrap().subscribe(
super::audio_service::NAME,
self.inner.clone(),
self.audio_enabled(),
);
if self.is_authed_view_camera_conn() {
if self.voice_calling || !self.audio_enabled() {
s.write().unwrap().subscribe(
super::audio_service::NAME,
self.inner.clone(),
self.audio_enabled(),
);
}
} else {
s.write().unwrap().subscribe(
super::audio_service::NAME,
self.inner.clone(),
self.audio_enabled(),
);
}
}
}
}
@@ -3316,6 +3446,7 @@ impl Connection {
fn portable_check(&mut self) {
if self.portable.is_installed
|| self.file_transfer.is_some()
|| self.view_camera
|| self.port_forward_socket.is_some()
|| !self.keyboard
{
@@ -3463,6 +3594,13 @@ impl Connection {
false
}
fn is_authed_view_camera_conn(&self) -> bool {
if let Some(id) = self.authed_conn_id.as_ref() {
return id.conn_type() == AuthConnType::ViewCamera;
}
false
}
#[cfg(feature = "unix-file-copy-paste")]
async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) {
let is_stopping_allowed = clip.is_stopping_allowed();
@@ -3966,7 +4104,6 @@ impl Retina {
}
mod raii {
// CONN_COUNT: remote connection count in fact
// ALIVE_CONNS: all connections, including unauthorized connections
// AUTHED_CONNS: all authorized connections
@@ -4001,7 +4138,7 @@ mod raii {
_ONCE.call_once(|| {
shutdown_hooks::add_shutdown_hook(connection_shutdown_hook);
});
if conn_type == AuthConnType::Remote {
if conn_type == AuthConnType::Remote || conn_type == AuthConnType::ViewCamera {
video_service::VIDEO_QOS
.lock()
.unwrap()
@@ -4024,12 +4161,12 @@ mod raii {
.send((conn_count, remote_count)));
}
pub fn remote_and_file_conn_count() -> usize {
pub fn non_port_forward_conn_count() -> usize {
AUTHED_CONNS
.lock()
.unwrap()
.iter()
.filter(|c| c.1 == AuthConnType::Remote || c.1 == AuthConnType::FileTransfer)
.filter(|c| c.1 != AuthConnType::PortForward)
.count()
}
@@ -4112,7 +4249,7 @@ mod raii {
impl Drop for AuthedConnID {
fn drop(&mut self) {
if self.1 == AuthConnType::Remote {
if self.1 == AuthConnType::Remote || self.1 == AuthConnType::ViewCamera {
scrap::codec::Encoder::update(scrap::codec::EncodingUpdate::Remove(self.0));
video_service::VIDEO_QOS
.lock()

View File

@@ -404,6 +404,7 @@ fn no_displays(displays: &Vec<Display>) -> bool {
}
}
#[inline]
#[cfg(not(windows))]
pub fn try_get_displays() -> ResultType<Vec<Display>> {

View File

@@ -501,8 +501,13 @@ pub fn try_start_record_cursor_pos() -> Option<thread::JoinHandle<()>> {
}
pub fn try_stop_record_cursor_pos() {
let count_lock = CONN_COUNT.lock().unwrap();
if *count_lock > 0 {
let remote_count = AUTHED_CONNS
.lock()
.unwrap()
.iter()
.filter(|c| c.1 == AuthConnType::Remote)
.count();
if remote_count > 0 {
return;
}
RECORD_CURSOR_POS_RUNNING.store(false, Ordering::SeqCst);

View File

@@ -717,7 +717,7 @@ pub mod client {
}
let frame_ptr = base.add(ADDR_CAPTURE_FRAME);
let data = slice::from_raw_parts(frame_ptr, (*frame_info).length);
Ok(Frame::PixelBuffer(PixelBuffer::new(
Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA(
data,
self.width,
self.height,
@@ -808,8 +808,13 @@ pub mod client {
},
ConnCount(None) => {
if !quick_support {
let cnt = crate::server::CONN_COUNT.lock().unwrap().clone();
stream.send(&Data::DataPortableService(ConnCount(Some(cnt)))).await.ok();
let remote_count = crate::server::AUTHED_CONNS
.lock()
.unwrap()
.iter()
.filter(|c| c.1 == crate::server::AuthConnType::Remote)
.count();
stream.send(&Data::DataPortableService(ConnCount(Some(remote_count)))).await.ok();
}
},
WillClose => {

View File

@@ -106,7 +106,7 @@ pub struct VideoQoS {
fps: u32,
ratio: f32,
users: HashMap<i32, UserData>,
displays: HashMap<usize, DisplayData>,
displays: HashMap<String, DisplayData>,
bitrate_store: u32,
adjust_ratio_instant: Instant,
abr_config: bool,
@@ -168,8 +168,8 @@ impl VideoQoS {
self.users.iter().any(|u| u.1.record)
}
pub fn set_support_changing_quality(&mut self, display_idx: usize, support: bool) {
if let Some(display) = self.displays.get_mut(&display_idx) {
pub fn set_support_changing_quality(&mut self, video_service_name: &str, support: bool) {
if let Some(display) = self.displays.get_mut(video_service_name) {
display.support_changing_quality = support;
}
}
@@ -346,16 +346,17 @@ impl VideoQoS {
// Common adjust functions
impl VideoQoS {
pub fn new_display(&mut self, display_idx: usize) {
self.displays.insert(display_idx, DisplayData::default());
pub fn new_display(&mut self, video_service_name: String) {
self.displays
.insert(video_service_name, DisplayData::default());
}
pub fn remove_display(&mut self, display_idx: usize) {
self.displays.remove(&display_idx);
pub fn remove_display(&mut self, video_service_name: &str) {
self.displays.remove(video_service_name);
}
pub fn update_display_data(&mut self, display_idx: usize, send_counter: usize) {
if let Some(display) = self.displays.get_mut(&display_idx) {
pub fn update_display_data(&mut self, video_service_name: &str, send_counter: usize) {
if let Some(display) = self.displays.get_mut(video_service_name) {
display.send_counter += send_counter;
}
self.adjust_fps();

View File

@@ -18,12 +18,7 @@
// to-do:
// https://slhck.info/video/2017/03/01/rate-control.html
use super::{
display_service::{check_display_changed, get_display_info},
service::ServiceTmpl,
video_qos::VideoQoS,
*,
};
use super::{display_service::check_display_changed, service::ServiceTmpl, video_qos::VideoQoS, *};
#[cfg(target_os = "linux")]
use crate::common::SimpleCallOnReturn;
#[cfg(target_os = "linux")]
@@ -65,7 +60,6 @@ use std::{
time::{self, Duration, Instant},
};
pub const NAME: &'static str = "video";
pub const OPTION_REFRESH: &'static str = "refresh";
lazy_static::lazy_static! {
@@ -133,10 +127,34 @@ impl VideoFrameController {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VideoSource {
Monitor,
Camera,
}
impl VideoSource {
pub fn service_name_prefix(&self) -> &'static str {
match self {
VideoSource::Monitor => "monitor",
VideoSource::Camera => "camera",
}
}
pub fn is_monitor(&self) -> bool {
matches!(self, VideoSource::Monitor)
}
pub fn is_camera(&self) -> bool {
matches!(self, VideoSource::Camera)
}
}
#[derive(Clone)]
pub struct VideoService {
sp: GenericService,
idx: usize,
source: VideoSource,
}
impl Deref for VideoService {
@@ -153,14 +171,15 @@ impl DerefMut for VideoService {
}
}
pub fn get_service_name(idx: usize) -> String {
format!("{}{}", NAME, idx)
pub fn get_service_name(source: VideoSource, idx: usize) -> String {
format!("{}{}", source.service_name_prefix(), idx)
}
pub fn new(idx: usize) -> GenericService {
pub fn new(source: VideoSource, idx: usize) -> GenericService {
let vs = VideoService {
sp: GenericService::new(get_service_name(idx), true),
sp: GenericService::new(get_service_name(source, idx), true),
idx,
source,
};
GenericService::run(&vs, run);
vs.sp
@@ -292,7 +311,10 @@ impl DerefMut for CapturerInfo {
}
}
fn get_capturer(current: usize, portable_service_running: bool) -> ResultType<CapturerInfo> {
fn get_capturer_monitor(
current: usize,
portable_service_running: bool,
) -> ResultType<CapturerInfo> {
#[cfg(target_os = "linux")]
{
if !is_x11() {
@@ -309,6 +331,7 @@ fn get_capturer(current: usize, portable_service_running: bool) -> ResultType<Ca
ndisplay
);
}
let display = displays.remove(current);
#[cfg(target_os = "linux")]
@@ -382,8 +405,59 @@ fn get_capturer(current: usize, portable_service_running: bool) -> ResultType<Ca
})
}
fn get_capturer_camera(current: usize) -> ResultType<CapturerInfo> {
let cameras = camera::Cameras::get_sync_cameras();
let ncamera = cameras.len();
if ncamera <= current {
bail!("Failed to get camera {}, cameras len: {}", current, ncamera,);
}
let Some(camera) = cameras.get(current) else {
bail!(
"Camera of index {} doesn't exist or platform not supported",
current
);
};
let capturer = camera::Cameras::get_capturer(current)?;
let (width, height) = (camera.width as usize, camera.height as usize);
let origin = (camera.x as i32, camera.y as i32);
let name = &camera.name;
let privacy_mode_id = get_privacy_mode_conn_id().unwrap_or(INVALID_PRIVACY_MODE_CONN_ID);
let _capturer_privacy_mode_id = privacy_mode_id;
log::debug!(
"#cameras={}, current={}, origin: {:?}, width={}, height={}, cpus={}/{}, name:{}",
ncamera,
current,
&origin,
width,
height,
num_cpus::get_physical(),
num_cpus::get(),
name,
);
return Ok(CapturerInfo {
origin,
width,
height,
ndisplay: ncamera,
current,
privacy_mode_id,
_capturer_privacy_mode_id: privacy_mode_id,
capturer,
});
}
fn get_capturer(
source: VideoSource,
current: usize,
portable_service_running: bool,
) -> ResultType<CapturerInfo> {
match source {
VideoSource::Monitor => get_capturer_monitor(current, portable_service_running),
VideoSource::Camera => get_capturer_camera(current),
}
}
fn run(vs: VideoService) -> ResultType<()> {
let _raii = Raii::new(vs.idx);
let _raii = Raii::new(vs.sp.name());
// Wayland only support one video capturer for now. It is ok to call ensure_inited() here.
//
// ensure_inited() is needed because clear() may be called.
@@ -406,7 +480,7 @@ fn run(vs: VideoService) -> ResultType<()> {
let display_idx = vs.idx;
let sp = vs.sp;
let mut c = get_capturer(display_idx, last_portable_service_running)?;
let mut c = get_capturer(vs.source, display_idx, last_portable_service_running)?;
#[cfg(windows)]
if !scrap::codec::enable_directx_capture() && !c.is_gdi() {
log::info!("disable dxgi with option, fall back to gdi");
@@ -423,11 +497,12 @@ fn run(vs: VideoService) -> ResultType<()> {
drop(video_qos);
let (mut encoder, encoder_cfg, codec_format, use_i444, recorder) = match setup_encoder(
&c,
display_idx,
sp.name(),
quality,
client_record,
record_incoming,
last_portable_service_running,
vs.source,
) {
Ok(result) => result,
Err(err) => {
@@ -441,26 +516,29 @@ fn run(vs: VideoService) -> ResultType<()> {
}));
setup_encoder(
&c,
display_idx,
sp.name(),
quality,
client_record,
record_incoming,
last_portable_service_running,
vs.source,
)?
}
};
#[cfg(feature = "vram")]
c.set_output_texture(encoder.input_texture());
#[cfg(target_os = "android")]
if let Err(e) = check_change_scale(encoder.is_hardware()) {
try_broadcast_display_changed(&sp, display_idx, &c, true).ok();
bail!(e);
if vs.source.is_monitor() {
if let Err(e) = check_change_scale(encoder.is_hardware()) {
try_broadcast_display_changed(&sp, display_idx, &c, true).ok();
bail!(e);
}
}
VIDEO_QOS.lock().unwrap().store_bitrate(encoder.bitrate());
VIDEO_QOS
.lock()
.unwrap()
.set_support_changing_quality(display_idx, encoder.support_changing_quality());
.set_support_changing_quality(&sp.name(), encoder.support_changing_quality());
log::info!("initial quality: {quality:?}");
if sp.is_option_true(OPTION_REFRESH) {
@@ -500,10 +578,12 @@ fn run(vs: VideoService) -> ResultType<()> {
client_record,
&mut send_counter,
&mut second_instant,
display_idx,
&sp.name(),
)?;
if sp.is_option_true(OPTION_REFRESH) {
let _ = try_broadcast_display_changed(&sp, display_idx, &c, true);
if vs.source.is_monitor() {
let _ = try_broadcast_display_changed(&sp, display_idx, &c, true);
}
log::info!("switch to refresh");
bail!("SWITCH");
}
@@ -527,10 +607,12 @@ fn run(vs: VideoService) -> ResultType<()> {
#[cfg(all(windows, feature = "vram"))]
if c.is_gdi() && encoder.input_texture() {
log::info!("changed to gdi when using vram");
VRamEncoder::set_fallback_gdi(display_idx, true);
VRamEncoder::set_fallback_gdi(sp.name(), true);
bail!("SWITCH");
}
check_privacy_mode_changed(&sp, display_idx, &c)?;
if vs.source.is_monitor() {
check_privacy_mode_changed(&sp, display_idx, &c)?;
}
#[cfg(windows)]
{
if crate::platform::windows::desktop_changed()
@@ -540,7 +622,7 @@ fn run(vs: VideoService) -> ResultType<()> {
}
}
let now = time::Instant::now();
if last_check_displays.elapsed().as_millis() > 1000 {
if vs.source.is_monitor() && last_check_displays.elapsed().as_millis() > 1000 {
last_check_displays = now;
// This check may be redundant, but it is better to be safe.
// The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough.
@@ -575,7 +657,7 @@ fn run(vs: VideoService) -> ResultType<()> {
{
#[cfg(feature = "vram")]
if try_gdi == 1 && !c.is_gdi() {
VRamEncoder::set_fallback_gdi(display_idx, false);
VRamEncoder::set_fallback_gdi(sp.name(), false);
}
try_gdi = 0;
}
@@ -635,7 +717,9 @@ fn run(vs: VideoService) -> ResultType<()> {
Err(err) => {
// This check may be redundant, but it is better to be safe.
// The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough.
try_broadcast_display_changed(&sp, display_idx, &c, true)?;
if vs.source.is_monitor() {
try_broadcast_display_changed(&sp, display_idx, &c, true)?;
}
#[cfg(windows)]
if !c.is_gdi() {
@@ -657,7 +741,9 @@ fn run(vs: VideoService) -> ResultType<()> {
let timeout_millis = 3_000u64;
let wait_begin = Instant::now();
while wait_begin.elapsed().as_millis() < timeout_millis as _ {
check_privacy_mode_changed(&sp, display_idx, &c)?;
if vs.source.is_monitor() {
check_privacy_mode_changed(&sp, display_idx, &c)?;
}
frame_controller.try_wait_next(&mut fetched_conn_ids, 300);
// break if all connections have received current frame
if fetched_conn_ids.len() >= frame_controller.send_conn_ids.len() {
@@ -676,32 +762,35 @@ fn run(vs: VideoService) -> ResultType<()> {
Ok(())
}
struct Raii(usize);
struct Raii(String);
impl Raii {
fn new(display_idx: usize) -> Self {
VIDEO_QOS.lock().unwrap().new_display(display_idx);
Raii(display_idx)
fn new(name: String) -> Self {
log::info!("new video service: {}", name);
VIDEO_QOS.lock().unwrap().new_display(name.clone());
Raii(name)
}
}
impl Drop for Raii {
fn drop(&mut self) {
log::info!("stop video service: {}", self.0);
#[cfg(feature = "vram")]
VRamEncoder::set_not_use(self.0, false);
VRamEncoder::set_not_use(self.0.clone(), false);
#[cfg(feature = "vram")]
Encoder::update(scrap::codec::EncodingUpdate::Check);
VIDEO_QOS.lock().unwrap().remove_display(self.0);
VIDEO_QOS.lock().unwrap().remove_display(&self.0);
}
}
fn setup_encoder(
c: &CapturerInfo,
display_idx: usize,
name: String,
quality: f32,
client_record: bool,
record_incoming: bool,
last_portable_service_running: bool,
source: VideoSource,
) -> ResultType<(
Encoder,
EncoderCfg,
@@ -711,14 +800,15 @@ fn setup_encoder(
)> {
let encoder_cfg = get_encoder_config(
&c,
display_idx,
name.to_string(),
quality,
client_record || record_incoming,
last_portable_service_running,
source,
);
Encoder::set_fallback(&encoder_cfg);
let codec_format = Encoder::negotiated_codec();
let recorder = get_recorder(record_incoming, display_idx);
let recorder = get_recorder(record_incoming, name);
let use_i444 = Encoder::use_i444(&encoder_cfg);
let encoder = Encoder::new(encoder_cfg.clone(), use_i444)?;
Ok((encoder, encoder_cfg, codec_format, use_i444, recorder))
@@ -726,15 +816,16 @@ fn setup_encoder(
fn get_encoder_config(
c: &CapturerInfo,
_display_idx: usize,
_name: String,
quality: f32,
record: bool,
_portable_service: bool,
_source: VideoSource,
) -> EncoderCfg {
#[cfg(all(windows, feature = "vram"))]
if _portable_service || c.is_gdi() {
if _portable_service || c.is_gdi() || _source == VideoSource::Camera {
log::info!("gdi:{}, portable:{}", c.is_gdi(), _portable_service);
VRamEncoder::set_not_use(_display_idx, true);
VRamEncoder::set_not_use(_name, true);
}
#[cfg(feature = "vram")]
Encoder::update(scrap::codec::EncodingUpdate::Check);
@@ -800,7 +891,7 @@ fn get_encoder_config(
}
}
fn get_recorder(record_incoming: bool, display: usize) -> Arc<Mutex<Option<Recorder>>> {
fn get_recorder(record_incoming: bool, video_service_name: String) -> Arc<Mutex<Option<Recorder>>> {
#[cfg(windows)]
let root = crate::platform::is_root();
#[cfg(not(windows))]
@@ -819,7 +910,7 @@ fn get_recorder(record_incoming: bool, display: usize) -> Arc<Mutex<Option<Recor
server: true,
id: Config::get_id(),
dir: crate::ui_interface::video_save_directory(root),
display,
video_service_name,
tx,
})
.map_or(Default::default(), |r| Arc::new(Mutex::new(Some(r))))
@@ -1004,7 +1095,9 @@ fn try_broadcast_display_changed(
(cap.origin.0, cap.origin.1, cap.width, cap.height),
) {
log::info!("Display {} changed", display);
if let Some(msg_out) = make_display_changed_msg(display_idx, Some(display)) {
if let Some(msg_out) =
make_display_changed_msg(display_idx, Some(display), VideoSource::Monitor)
{
let msg_out = Arc::new(msg_out);
sp.send_shared(msg_out.clone());
// switch display may occur before the first video frame, add snapshot to send to new subscribers
@@ -1021,10 +1114,16 @@ fn try_broadcast_display_changed(
pub fn make_display_changed_msg(
display_idx: usize,
opt_display: Option<DisplayInfo>,
source: VideoSource,
) -> Option<Message> {
let display = match opt_display {
Some(d) => d,
None => get_display_info(display_idx)?,
None => match source {
VideoSource::Monitor => display_service::get_display_info(display_idx)?,
VideoSource::Camera => camera::Cameras::get_sync_cameras()
.get(display_idx)?
.clone(),
},
};
let mut misc = Misc::new();
misc.set_switch_display(SwitchDisplay {
@@ -1033,13 +1132,24 @@ pub fn make_display_changed_msg(
y: display.y,
width: display.width,
height: display.height,
cursor_embedded: display_service::capture_cursor_embedded(),
cursor_embedded: match source {
VideoSource::Monitor => display_service::capture_cursor_embedded(),
VideoSource::Camera => false,
},
#[cfg(not(target_os = "android"))]
resolutions: Some(SupportedResolutions {
resolutions: if display.name.is_empty() {
vec![]
} else {
crate::platform::resolutions(&display.name)
resolutions: match source {
VideoSource::Monitor => {
if display.name.is_empty() {
vec![]
} else {
crate::platform::resolutions(&display.name)
}
}
VideoSource::Camera => camera::Cameras::get_camera_resolution(display_idx)
.ok()
.into_iter()
.collect(),
},
..SupportedResolutions::default()
})
@@ -1059,7 +1169,7 @@ fn check_qos(
client_record: bool,
send_counter: &mut usize,
second_instant: &mut Instant,
display_idx: usize,
name: &str,
) -> ResultType<()> {
let mut video_qos = VIDEO_QOS.lock().unwrap();
*spf = video_qos.spf();
@@ -1082,7 +1192,7 @@ fn check_qos(
}
if second_instant.elapsed() > Duration::from_secs(1) {
*second_instant = Instant::now();
video_qos.update_display_data(display_idx, *send_counter);
video_qos.update_display_data(&name, *send_counter);
*send_counter = 0;
}
drop(video_qos);

View File

@@ -19,6 +19,7 @@ impl InvokeUiCM for SciterHandler {
&make_args!(
client.id,
client.is_file_transfer,
client.is_view_camera,
client.port_forward.clone(),
client.peer_id.clone(),
client.name.clone(),

View File

@@ -356,7 +356,7 @@ function bring_to_top(idx=-1) {
}
}
handler.addConnection = function(id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) {
handler.addConnection = function(id, is_file_transfer, is_view_camera, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) {
stdout.println("new connection #" + id + ": " + peer_id);
var conn;
connections.map(function(c) {
@@ -550,7 +550,7 @@ function adjustHeader() {
view.on("size", adjustHeader);
// handler.addConnection(0, false, 0, "", "test1", true, false, false, true, true);
// handler.addConnection(1, false, 0, "", "test2--------", true, false, false, false, false);
// handler.addConnection(2, false, 0, "", "test3", true, false, false, false, false);
// handler.addConnection(0, false, false, 0, "", "test1", true, false, false, true, true);
// handler.addConnection(1, false, false, 0, "", "test2--------", true, false, false, false, false);
// handler.addConnection(2, false, false, 0, "", "test3", true, false, false, false, false);
// handler.newMessage(0, 'h');

View File

@@ -316,6 +316,7 @@ impl InvokeUiSession for SciterHandler {
ConnType::RDP => {}
ConnType::PORT_FORWARD => {}
ConnType::FILE_TRANSFER => {}
ConnType::VIEW_CAMERA => {}
ConnType::DEFAULT_CONN => {
crate::keyboard::client::start_grab_loop();
}
@@ -557,6 +558,8 @@ impl SciterSession {
let conn_type = if cmd.eq("--file-transfer") {
ConnType::FILE_TRANSFER
} else if cmd.eq("--view-camera") {
ConnType::VIEW_CAMERA
} else if cmd.eq("--port-forward") {
ConnType::PORT_FORWARD
} else if cmd.eq("--rdp") {

View File

@@ -47,6 +47,7 @@ pub struct Client {
pub authorized: bool,
pub disconnected: bool,
pub is_file_transfer: bool,
pub is_view_camera: bool,
pub port_forward: String,
pub name: String,
pub peer_id: String,
@@ -128,6 +129,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
&self,
id: i32,
is_file_transfer: bool,
is_view_camera: bool,
port_forward: String,
peer_id: String,
name: String,
@@ -147,6 +149,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
authorized,
disconnected: false,
is_file_transfer,
is_view_camera,
port_forward,
name: name.clone(),
peer_id: peer_id.clone(),
@@ -402,9 +405,9 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
}
Ok(Some(data)) => {
match data {
Data::Login{id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => {
Data::Login{id, is_file_transfer, is_view_camera, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => {
log::debug!("conn_id: {}", id);
self.cm.add_connection(id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone());
self.cm.add_connection(id, is_file_transfer, is_view_camera, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone());
self.conn_id = id;
#[cfg(target_os = "windows")]
{
@@ -672,6 +675,7 @@ pub async fn start_listen<T: InvokeUiCM>(
Some(Data::Login {
id,
is_file_transfer,
is_view_camera,
port_forward,
peer_id,
name,
@@ -690,6 +694,7 @@ pub async fn start_listen<T: InvokeUiCM>(
cm.add_connection(
id,
is_file_transfer,
is_view_camera,
port_forward,
peer_id,
name,

View File

@@ -190,6 +190,10 @@ impl<T: InvokeUiSession> Session<T> {
.eq(&ConnType::FILE_TRANSFER)
}
pub fn is_view_camera(&self) -> bool {
self.lc.read().unwrap().conn_type.eq(&ConnType::VIEW_CAMERA)
}
pub fn is_port_forward(&self) -> bool {
let conn_type = self.lc.read().unwrap().conn_type;
conn_type == ConnType::PORT_FORWARD || conn_type == ConnType::RDP
@@ -1630,7 +1634,12 @@ impl<T: InvokeUiSession> Interface for Session<T> {
if pi.displays.is_empty() {
self.lc.write().unwrap().handle_peer_info(&pi);
self.update_privacy_mode();
self.msgbox("error", "Remote Error", "No Displays", "");
let msg = if self.is_view_camera() {
"No cameras"
} else {
"No displays"
};
self.msgbox("error", "Error", msg, "");
return;
}
self.try_change_init_resolution(pi.current_display);