terminal works basically. (#12189)

* terminal works basically.
todo:
- persistent
- sessions restore
- web
- mobile

* missed terminal persistent option change

* android sdk 34 -> 35

* +#![cfg_attr(lt_1_77, feature(c_str_literals))]

* fixing ci

* fix ci

* fix ci for android

* try "Fix Android SDK Platform 35"

* fix android 34

* revert flutter_plugin_android_lifecycle to 2.0.17 which used in rustdesk 1.4.0

* refactor, but break something of desktop terminal (new tab showing loading)

* fix connecting...
This commit is contained in:
RustDesk
2025-07-01 13:12:55 +08:00
committed by GitHub
parent ee5cdc3155
commit 5faf0ad3cf
130 changed files with 4064 additions and 4247 deletions

View File

@@ -122,9 +122,9 @@ class MainService : Service() {
val authorized = jsonObject["authorized"] as Boolean
val isFileTransfer = jsonObject["is_file_transfer"] as Boolean
val type = if (isFileTransfer) {
translate("File Connection")
translate("Transfer file")
} else {
translate("Screen Connection")
translate("Share screen")
}
if (authorized) {
if (!isFileTransfer && !isStart) {

View File

@@ -30,6 +30,7 @@ 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 'mobile/pages/terminal_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;
@@ -99,6 +100,7 @@ enum DesktopType {
remote,
fileTransfer,
viewCamera,
terminal,
cm,
portForward,
}
@@ -1571,7 +1573,9 @@ bool option2bool(String option, String value) {
String bool2option(String option, bool b) {
String res;
if (option.startsWith('enable-') && option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
if (option.startsWith('enable-') &&
option != kOptionEnableUdpPunch &&
option != kOptionEnableIpv6Punch) {
res = b ? defaultOptionYes : 'N';
} else if (option.startsWith('allow-') ||
option == kOptionStopService ||
@@ -2117,6 +2121,7 @@ enum UriLinkType {
viewCamera,
portForward,
rdp,
terminal,
}
// uri link handler
@@ -2181,6 +2186,11 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
id = args[i + 1];
i++;
break;
case '--terminal':
type = UriLinkType.terminal;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
@@ -2230,6 +2240,12 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
password: password, forceRelay: forceRelay);
});
break;
case UriLinkType.terminal:
Future.delayed(Duration.zero, () {
rustDeskWinManager.newTerminal(id!,
password: password, forceRelay: forceRelay);
});
break;
}
return true;
@@ -2247,7 +2263,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
"file-transfer",
"view-camera",
"port-forward",
"rdp"
"rdp",
"terminal"
];
if (uri.authority.isEmpty &&
uri.path.split('').every((char) => char == '/')) {
@@ -2276,21 +2293,10 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
}
}
} else if (options.contains(uri.authority)) {
final optionIndex = options.indexOf(uri.authority);
command = '--${uri.authority}';
if (uri.path.length > 1) {
id = uri.path.substring(1);
}
if (isMobile && id != null) {
if (optionIndex == 0 || optionIndex == 1) {
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;
}
} else if (uri.authority.length > 2 &&
(uri.path.length <= 1 ||
(uri.path == '/r' || uri.path.startsWith('/r@')))) {
@@ -2314,13 +2320,25 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
}
}
if (isMobile) {
if (id != null) {
final forceRelay = queryParameters["relay"] != null;
if (isMobile && id != null) {
final forceRelay = queryParameters["relay"] != null;
final password = queryParameters["password"];
// Determine connection type based on command
if (command == '--file-transfer') {
connect(Get.context!, id,
forceRelay: forceRelay, password: queryParameters["password"]);
return null;
isFileTransfer: true, forceRelay: forceRelay, password: password);
} else if (command == '--view-camera') {
connect(Get.context!, id,
isViewCamera: true, forceRelay: forceRelay, password: password);
} else if (command == '--terminal') {
connect(Get.context!, id,
isTerminal: true, forceRelay: forceRelay, password: password);
} else {
// Default to remote desktop for '--connect', '--play', or direct connection
connect(Get.context!, id, forceRelay: forceRelay, password: password);
}
return null;
}
List<String> args = List.empty(growable: true);
@@ -2342,6 +2360,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
connectMainDesktop(String id,
{required bool isFileTransfer,
required bool isViewCamera,
required bool isTerminal,
required bool isTcpTunneling,
required bool isRDP,
bool? forceRelay,
@@ -2366,6 +2385,12 @@ connectMainDesktop(String id,
isSharedPassword: isSharedPassword,
connToken: connToken,
forceRelay: forceRelay);
} else if (isTerminal) {
await rustDeskWinManager.newTerminal(id,
password: password,
isSharedPassword: isSharedPassword,
connToken: connToken,
forceRelay: forceRelay);
} else {
await rustDeskWinManager.newRemoteDesktop(id,
password: password,
@@ -2382,6 +2407,7 @@ connectMainDesktop(String id,
connect(BuildContext context, String id,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false,
bool isTcpTunneling = false,
bool isRDP = false,
bool forceRelay = false,
@@ -2404,7 +2430,7 @@ connect(BuildContext context, String id,
id = id.replaceAll(' ', '');
final oldId = id;
id = await bind.mainHandleRelayId(id: id);
final forceRelay2 = id != oldId || forceRelay;
forceRelay = id != oldId || forceRelay;
assert(!(isFileTransfer && isTcpTunneling && isRDP),
"more than one connect type");
@@ -2414,17 +2440,19 @@ connect(BuildContext context, String id,
id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay2,
forceRelay: forceRelay,
);
} else {
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
'id': id,
'isFileTransfer': isFileTransfer,
'isViewCamera': isViewCamera,
'isTerminal': isTerminal,
'isTcpTunneling': isTcpTunneling,
'isRDP': isRDP,
'password': password,
@@ -2458,7 +2486,10 @@ connect(BuildContext context, String id,
context,
MaterialPageRoute(
builder: (BuildContext context) => FileManagerPage(
id: id, password: password, isSharedPassword: isSharedPassword),
id: id,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay),
),
);
}
@@ -2473,7 +2504,6 @@ connect(BuildContext context, String id,
id: id,
toolbarState: ToolbarState(),
password: password,
forceRelay: forceRelay,
isSharedPassword: isSharedPassword,
),
),
@@ -2483,10 +2513,25 @@ connect(BuildContext context, String id,
context,
MaterialPageRoute(
builder: (BuildContext context) => ViewCameraPage(
id: id, password: password, isSharedPassword: isSharedPassword),
id: id,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay),
),
);
}
} else if (isTerminal) {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => TerminalPage(
id: id,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay,
),
),
);
} else {
if (isWeb) {
Navigator.push(
@@ -2497,7 +2542,6 @@ connect(BuildContext context, String id,
id: id,
toolbarState: ToolbarState(),
password: password,
forceRelay: forceRelay,
isSharedPassword: isSharedPassword,
),
),
@@ -2507,7 +2551,10 @@ connect(BuildContext context, String id,
context,
MaterialPageRoute(
builder: (BuildContext context) => RemotePage(
id: id, password: password, isSharedPassword: isSharedPassword),
id: id,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay),
),
);
}

View File

@@ -491,6 +491,7 @@ abstract class BasePeerCard extends StatelessWidget {
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false,
bool isTerminal = false,
}) {
return MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
@@ -506,6 +507,7 @@ abstract class BasePeerCard extends StatelessWidget {
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP,
isTerminal: isTerminal,
);
},
padding: menuPadding,
@@ -541,6 +543,15 @@ abstract class BasePeerCard extends StatelessWidget {
);
}
@protected
MenuEntryBase<String> _terminalAction(BuildContext context) {
return _connectCommonAction(
context,
translate('Terminal'),
isTerminal: true,
);
}
@protected
MenuEntryBase<String> _tcpTunnelingAction(BuildContext context) {
return _connectCommonAction(
@@ -892,6 +903,7 @@ class RecentPeerCard extends BasePeerCard {
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
final List favs = (await bind.mainGetFav()).toList();
@@ -952,6 +964,7 @@ class FavoritePeerCard extends BasePeerCard {
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -1006,6 +1019,7 @@ class DiscoveredPeerCard extends BasePeerCard {
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
final List favs = (await bind.mainGetFav()).toList();
@@ -1060,6 +1074,7 @@ class AddressBookPeerCard extends BasePeerCard {
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -1195,6 +1210,7 @@ class MyGroupPeerCard extends BasePeerCard {
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
@@ -1420,7 +1436,8 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false}) async {
bool isRDP = false,
bool isTerminal = false}) async {
var password = '';
bool isSharedPassword = false;
if (tab == PeerTabIndex.ab) {
@@ -1444,6 +1461,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
password: password,
isSharedPassword: isSharedPassword,
isFileTransfer: isFileTransfer,
isTerminal: isTerminal,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP);

View File

@@ -243,7 +243,8 @@ List<(String, String)> otherDefaultSettings() {
(
'Use all my displays for the remote session',
kKeyUseAllMyDisplaysForTheRemoteSession
)
),
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
];
return v;

View File

@@ -154,36 +154,38 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
onPressed: () => ffi.cursorModel.reset()));
}
// https://github.com/rustdesk/rustdesk/pull/9731
// Does not work for connection established by "accept".
connectWithToken(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false}) {
bool isTcpTunneling = false,
bool isTerminal = false}) {
final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId);
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
isTcpTunneling: isTcpTunneling,
connToken: connToken);
}
// transferFile
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('Transfer file')),
onPressed: () => connectWithToken(isFileTransfer: true)),
);
}
// viewCamera
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('View camera')),
onPressed: () => connectWithToken(isViewCamera: true)),
);
}
// tcpTunneling
if (isDefaultConn && isDesktop) {
v.add(
TTextMenu(
child: Text(translate('Terminal')),
onPressed: () => connectWithToken(isTerminal: true)),
);
v.add(
TTextMenu(
child: Text(translate('TCP tunneling')),

View File

@@ -27,7 +27,6 @@ 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";
@@ -47,6 +46,7 @@ const String kAppTypeDesktopRemote = "remote";
const String kAppTypeDesktopFileTransfer = "file transfer";
const String kAppTypeDesktopViewCamera = "view camera";
const String kAppTypeDesktopPortForward = "port forward";
const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowGetWindowInfo = "get_window_info";
@@ -62,6 +62,7 @@ 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 kWindowEventNewTerminal = "new_terminal";
const String kWindowEventActiveSession = "active_session";
const String kWindowEventActiveDisplaySession = "active_display_session";
const String kWindowEventGetRemoteList = "get_remote_list";
@@ -103,6 +104,8 @@ const String kOptionEnableClipboard = "enable-clipboard";
const String kOptionEnableFileTransfer = "enable-file-transfer";
const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTerminal = "enable-terminal";
const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";

View File

@@ -327,10 +327,15 @@ class _ConnectionPageState extends State<ConnectionPage>
/// Callback for the connect button.
/// Connects to the selected peer.
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
void onConnect(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false}) {
var id = _idController.id;
connect(context, id,
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal);
}
/// UI for the remote ID TextField.
@@ -527,22 +532,23 @@ class _ConnectionPageState extends State<ConnectionPage>
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Obx(() {
var offset = Offset(0, 0);
return InkWell(
child: _menuOpen.value
? Transform.rotate(
angle: pi,
child: Icon(IconFont.more, size: 14),
)
: Icon(IconFont.more, size: 14),
onTapDown: (e) {
offset = e.globalPosition;
},
onTap: () async {
_menuOpen.value = true;
final x = offset.dx;
final y = offset.dy;
child: StatefulBuilder(
builder: (context, setState) {
var offset = Offset(0, 0);
return Obx(() => InkWell(
child: _menuOpen.value
? Transform.rotate(
angle: pi,
child: Icon(IconFont.more, size: 14),
)
: Icon(IconFont.more, size: 14),
onTapDown: (e) {
offset = e.globalPosition;
},
onTap: () async {
_menuOpen.value = true;
final x = offset.dx;
final y = offset.dy;
await mod_menu
.showMenu(
context: context,
@@ -556,6 +562,10 @@ class _ConnectionPageState extends State<ConnectionPage>
'View camera',
() => onConnect(isViewCamera: true)
),
(
'Terminal',
() => onConnect(isTerminal: true)
),
]
.map((e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
@@ -583,8 +593,9 @@ class _ConnectionPageState extends State<ConnectionPage>
_menuOpen.value = false;
});
},
);
}),
));
},
),
),
),
]),

View File

@@ -786,6 +786,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
call.arguments['id'],
isFileTransfer: call.arguments['isFileTransfer'],
isViewCamera: call.arguments['isViewCamera'],
isTerminal: call.arguments['isTerminal'],
isTcpTunneling: call.arguments['isTcpTunneling'],
isRDP: call.arguments['isRDP'],
password: call.arguments['password'],

View File

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

View File

@@ -355,6 +355,7 @@ Widget buildConnectionCard(Client client) {
_CmHeader(client: client),
client.type_() == ClientType.file ||
client.type_() == ClientType.portForward ||
client.type_() == ClientType.terminal ||
client.disconnected
? Offstage()
: _PrivilegeBoard(client: client),
@@ -499,7 +500,36 @@ class _CmHeaderState extends State<_CmHeader>
"(${client.peerId})",
style: TextStyle(color: Colors.white, fontSize: 14),
),
).marginOnly(bottom: 10.0),
),
if (client.type_() == ClientType.terminal)
FittedBox(
child: Text(
translate("Terminal"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.type_() == ClientType.file)
FittedBox(
child: Text(
translate("File Transfer"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.type_() == ClientType.camera)
FittedBox(
child: Text(
translate("View Camera"),
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
if (client.portForward.isNotEmpty)
FittedBox(
child: Text(
"Port Forward: ${client.portForward}",
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
SizedBox(height: 10.0),
FittedBox(
child: Row(
children: [

View File

@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../../models/model.dart';
/// Manages terminal connections to ensure one FFI instance per peer
class TerminalConnectionManager {
static final Map<String, FFI> _connections = {};
static final Map<String, int> _connectionRefCount = {};
// Track service IDs per peer
static final Map<String, String> _serviceIds = {};
/// Get or create an FFI instance for a peer
static FFI getConnection({
required String peerId,
required String? password,
required bool? isSharedPassword,
required bool? forceRelay,
required String? connToken,
}) {
final existingFfi = _connections[peerId];
if (existingFfi != null && !existingFfi.closed) {
// Increment reference count
_connectionRefCount[peerId] = (_connectionRefCount[peerId] ?? 0) + 1;
debugPrint('[TerminalConnectionManager] Reusing existing connection for peer $peerId. Reference count: ${_connectionRefCount[peerId]}');
return existingFfi;
}
// Create new FFI instance for first terminal
debugPrint('[TerminalConnectionManager] Creating new terminal connection for peer $peerId');
final ffi = FFI(null);
ffi.start(
peerId,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay,
connToken: connToken,
isTerminal: true,
);
_connections[peerId] = ffi;
_connectionRefCount[peerId] = 1;
// Register the FFI instance with Get for dependency injection
Get.put<FFI>(ffi, tag: 'terminal_$peerId');
debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}');
return ffi;
}
/// Release a connection reference
static void releaseConnection(String peerId) {
final refCount = _connectionRefCount[peerId] ?? 0;
debugPrint('[TerminalConnectionManager] Releasing connection for peer $peerId. Current ref count: $refCount');
if (refCount <= 1) {
// Last reference, close the connection
final ffi = _connections[peerId];
if (ffi != null) {
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
ffi.close();
_connections.remove(peerId);
_connectionRefCount.remove(peerId);
Get.delete<FFI>(tag: 'terminal_$peerId');
}
} else {
// Decrement reference count
_connectionRefCount[peerId] = refCount - 1;
debugPrint('[TerminalConnectionManager] Connection still in use. New ref count: ${_connectionRefCount[peerId]}');
}
}
/// Check if a connection exists for a peer
static bool hasConnection(String peerId) {
final ffi = _connections[peerId];
return ffi != null && !ffi.closed;
}
/// Get existing connection without creating new one
static FFI? getExistingConnection(String peerId) {
return _connections[peerId];
}
/// Get connection count for debugging
static int getConnectionCount() => _connections.length;
/// Get terminal count for a peer
static int getTerminalCount(String peerId) => _connectionRefCount[peerId] ?? 0;
/// Get service ID for a peer
static String? getServiceId(String peerId) => _serviceIds[peerId];
/// Set service ID for a peer
static void setServiceId(String peerId, String serviceId) {
_serviceIds[peerId] = serviceId;
debugPrint('[TerminalConnectionManager] Service ID for $peerId: $serviceId');
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final DesktopTabController tabController;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
late FFI _ffi;
late TerminalModel _terminalModel;
@override
void initState() {
super.initState();
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);
// Check if this is a new connection or additional terminal
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
if (!isExistingConnection) {
// First terminal - show loading dialog, wait for onReady
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
} else {
// Additional terminal - connection already established
// Open the terminal directly
_terminalModel.openTerminal();
}
});
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
backgroundOpacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
),
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -0,0 +1,384 @@
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
import 'terminal_page.dart';
import 'terminal_connection_manager.dart';
import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const TerminalTabPage({Key? key, required this.params}) : super(key: key);
@override
State<TerminalTabPage> createState() => _TerminalTabPageState(params);
}
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
tabController.onSelected = (id) {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
terminalId: terminalId,
password: params['password'],
isSharedPassword: params['isSharedPassword'],
forceRelay: params['forceRelay'],
connToken: params['connToken'],
));
}
TabInfo _createTerminalTab({
required String peerId,
required int terminalId,
String? password,
bool? isSharedPassword,
bool? forceRelay,
String? connToken,
}) {
final tabKey = '${peerId}_$terminalId';
return TabInfo(
key: tabKey,
label: '$peerId #$terminalId',
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
// Close the terminal session first
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi != null) {
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
await terminalModel.closeTerminal();
}
}
// Then close the tab
tabController.closeBy(tabKey);
},
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
),
);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
// New tab menu item
menu.add(MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('New tab'),
style: style,
),
proc: () {
_addNewTerminal(peerId);
cancelFunc();
// Also try to close any BotToast overlays
BotToast.cleanAll();
},
padding: padding,
));
menu.add(MenuEntryDivider());
menu.add(MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Keep terminal sessions on disconnect'),
getter: () async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
return bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
},
setter: (bool v) async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
bind.sessionToggleOption(
sessionId: ffi.sessionId,
value: kOptionTerminalPersistent,
);
},
padding: padding,
));
return mod_menu.PopupMenu<String>(
items: menu
.map((e) => e.build(
context,
const MenuConfig(
commonColor: CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight: CustomPopupMenuTheme.dividerHeight,
),
))
.expand((i) => i)
.toList(),
);
}
@override
void initState() {
super.initState();
// Add keyboard shortcut handler
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
print(
"[Remote Terminal] call ${call.method} with args ${call.arguments} from window $fromWindowId");
if (call.method == kWindowEventNewTerminal) {
final args = jsonDecode(call.arguments);
final id = args['id'];
windowOnTop(windowId());
// Allow multiple terminals for the same connection
final terminalId = args['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: id,
terminalId: terminalId,
password: args['password'],
isSharedPassword: args['isSharedPassword'],
forceRelay: args['forceRelay'],
connToken: args['connToken'],
));
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
}
});
Future.delayed(Duration.zero, () {
restoreWindowPosition(WindowType.Terminal, windowId: windowId());
});
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
super.dispose();
}
bool _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
// Use Cmd+T on macOS, Ctrl+Shift+T on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyT) {
if (isMacOS &&
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+T (standard for new tab)
_addNewTerminalForCurrentPeer();
return true;
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// Other platforms: Ctrl+Shift+T (to avoid conflict with Ctrl+T in terminal)
_addNewTerminalForCurrentPeer();
return true;
}
}
// Use Cmd+W on macOS, Ctrl+Shift+W on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyW) {
if (isMacOS &&
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
return true;
}
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
tabController.closeBy(currentTab.key);
return true;
}
}
}
// Use Alt+Left/Right for tab navigation (avoids conflicts)
if (HardwareKeyboard.instance.isAltPressed) {
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
// Previous tab
final currentIndex = tabController.state.value.selected;
if (currentIndex > 0) {
tabController.jumpTo(currentIndex - 1);
}
return true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
// Next tab
final currentIndex = tabController.state.value.selected;
if (currentIndex < tabController.length - 1) {
tabController.jumpTo(currentIndex + 1);
}
return true;
}
}
// Check for Cmd/Ctrl + Number (switch to specific tab)
final numberKeys = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
for (int i = 0; i < numberKeys.length; i++) {
if (event.logicalKey == numberKeys[i] &&
((isMacOS && HardwareKeyboard.instance.isMetaPressed) ||
(!isMacOS && HardwareKeyboard.instance.isControlPressed))) {
if (i < tabController.length) {
tabController.jumpTo(i);
return true;
}
}
}
}
return false;
}
void _addNewTerminal(String peerId) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) => tab.key.startsWith('$peerId\_'),
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
final terminalId = _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: peerId,
terminalId: terminalId,
password: page.password,
isSharedPassword: page.isSharedPassword,
forceRelay: page.forceRelay,
connToken: page.connToken,
));
}
}
void _addNewTerminalForCurrentPeer() {
final currentTab = tabController.state.value.selectedTabInfo;
final parts = currentTab.key.split('_');
if (parts.isNotEmpty) {
final peerId = parts[0];
_addNewTerminal(peerId);
}
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).cardColor,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: _buildAddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
// Extract peerId from tab key (format: "peerId_terminalId")
final parts = key.split('_');
if (parts.isEmpty) return Container();
final peerId = parts[0];
return _tabMenuBuilder(peerId, () {});
},
));
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Container(
decoration: BoxDecoration(
border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
);
}
void onRemoveId(String id) {
if (tabController.state.value.tabs.isEmpty) {
WindowController.fromWindowId(windowId()).close();
}
}
int windowId() {
return widget.params["windowId"];
}
Widget _buildAddButton() {
return ActionIcon(
message: 'New tab',
icon: IconFont.add,
onTap: () {
_addNewTerminalForCurrentPeer();
},
isClose: false,
);
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.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;
}
}
}

View File

@@ -515,8 +515,6 @@ class ImagePaint extends StatefulWidget {
}
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;

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hbb/desktop/pages/terminal_tab_page.dart';
class DesktopTerminalScreen extends StatelessWidget {
final Map<String, dynamic> params;
const DesktopTerminalScreen({Key? key, required this.params})
: super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
],
child: Scaffold(
backgroundColor: isLinux ? Colors.transparent : null,
body: TerminalTabPage(
params: params,
),
),
);
}
}

View File

@@ -54,6 +54,7 @@ enum DesktopTabType {
fileTransfer,
viewCamera,
portForward,
terminal,
install,
}

View File

@@ -14,6 +14,7 @@ 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/screen/desktop_terminal_screen.dart';
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
@@ -91,6 +92,12 @@ Future<void> main(List<String> args) async {
kAppTypeDesktopPortForward,
);
break;
case WindowType.Terminal:
desktopType = DesktopType.terminal;
runMultiWindow(
argument,
kAppTypeDesktopTerminal,
);
default:
break;
}
@@ -211,6 +218,11 @@ void runMultiWindow(
params: argument,
);
break;
case kAppTypeDesktopTerminal:
widget = DesktopTerminalScreen(
params: argument,
);
break;
default:
// no such appType
exit(0);
@@ -257,6 +269,9 @@ void runMultiWindow(
case kAppTypeDesktopPortForward:
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
break;
case kAppTypeDesktopTerminal:
await restoreWindowPosition(WindowType.Terminal, windowId: kWindowId!);
break;
default:
// no such appType
exit(0);

View File

@@ -12,11 +12,12 @@ import '../../common/widgets/dialog.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
{Key? key, required this.id, this.password, this.isSharedPassword})
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
@@ -74,7 +75,8 @@ class _FileManagerPageState extends State<FileManagerPage> {
gFFI.start(widget.id,
isFileTransfer: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword);
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay);
WidgetsBinding.instance.addPostFrameCallback((_) {
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);

View File

@@ -205,13 +205,13 @@ class WebHomePage extends StatelessWidget {
}
bool isFileTransfer = false;
bool isViewCamera = false;
bool isTerminal = false;
String? id;
String? password;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case '--connect':
case '--play':
isFileTransfer = false;
id = args[i + 1];
i++;
break;
@@ -225,6 +225,11 @@ class WebHomePage extends StatelessWidget {
id = args[i + 1];
i++;
break;
case '--terminal':
isTerminal = true;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
@@ -234,7 +239,11 @@ class WebHomePage extends StatelessWidget {
}
}
if (id != null) {
connect(context, id, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, password: password);
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
password: password);
}
}
}

View File

@@ -40,12 +40,13 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
}
class RemotePage extends StatefulWidget {
RemotePage({Key? key, required this.id, this.password, this.isSharedPassword})
RemotePage({Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<RemotePage> createState() => _RemotePageState(id);
@@ -89,6 +90,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);

View File

@@ -17,7 +17,7 @@ import 'home_page.dart';
class ServerPage extends StatefulWidget implements PageShape {
@override
final title = translate("Share Screen");
final title = translate("Share screen");
@override
final icon = const Icon(Icons.mobile_screen_share);
@@ -649,8 +649,8 @@ class ConnectionManager extends StatelessWidget {
children: serverModel.clients
.map((client) => PaddingCard(
title: translate(client.isFileTransfer
? "File Connection"
: "Screen Connection"),
? "Transfer file"
: "Share screen"),
titleIcon: client.isFileTransfer
? Icon(Icons.folder_outlined)
: Icon(Icons.mobile_screen_share),

View File

@@ -815,7 +815,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
!outgoingOnly &&
!hideSecuritySettings)
SettingsSection(
title: Text(translate("Share Screen")),
title: Text(translate("Share screen")),
tiles: shareScreenTiles,
),
if (!bind.isIncomingOnly()) defaultDisplaySection(),

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.isSharedPassword,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final terminalId = 0;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
late FFI _ffi;
late TerminalModel _terminalModel;
@override
void initState() {
super.initState();
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
backgroundOpacity: 0.7,
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
),
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -39,12 +39,13 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
class ViewCameraPage extends StatefulWidget {
ViewCameraPage(
{Key? key, required this.id, this.password, this.isSharedPassword})
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
@@ -88,6 +89,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);

View File

@@ -23,6 +23,7 @@ import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/plugin/event.dart';
import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
@@ -311,6 +312,8 @@ class FfiModel with ChangeNotifier {
} else if (name == 'chat_server_mode') {
parent.target?.chatModel
.receive(int.parse(evt['id'] as String), evt['text'] ?? '');
} else if (name == 'terminal_response') {
parent.target?.routeTerminalResponse(evt);
} else if (name == 'file_dir') {
parent.target?.fileModel.receiveFileDir(evt);
} else if (name == 'empty_dirs') {
@@ -1076,9 +1079,14 @@ 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.terminal) {
// Call onReady on all registered terminal models
final models = parent.target?._terminalModels.values ?? [];
for (final model in models) {
model.onReady();
}
} else if (connType == ConnType.defaultConn ||
connType == ConnType.viewCamera) {
List<Display> newDisplays = [];
@@ -2828,7 +2836,14 @@ class ElevationModel with ChangeNotifier {
}
// The index values of `ConnType` are same as rust protobuf.
enum ConnType { defaultConn, fileTransfer, portForward, rdp, viewCamera }
enum ConnType {
defaultConn,
fileTransfer,
portForward,
rdp,
viewCamera,
terminal
}
/// Flutter state manager and data communication with the Rust core.
class FFI {
@@ -2863,6 +2878,12 @@ class FFI {
late final Peers favoritePeersModel; // global
late final Peers lanPeersModel; // global
// Terminal model registry for multiple terminals
final Map<int, TerminalModel> _terminalModels = {};
// Getter for terminal models
Map<int, TerminalModel> get terminalModels => _terminalModels;
FFI(SessionID? sId) {
sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId);
imageModel = ImageModel(WeakReference(this));
@@ -2910,6 +2931,7 @@ class FFI {
bool isViewCamera = false,
bool isPortForward = false,
bool isRdp = false,
bool isTerminal = false,
String? switchUuid,
String? password,
bool? isSharedPassword,
@@ -2925,7 +2947,10 @@ class FFI {
assert(
(!(isPortForward && isViewCamera)) &&
(!(isViewCamera && isPortForward)) &&
(!(isPortForward && isFileTransfer)),
(!(isPortForward && isFileTransfer)) &&
(!(isTerminal && isFileTransfer)) &&
(!(isTerminal && isViewCamera)) &&
(!(isTerminal && isPortForward)),
'more than one connect type');
if (isFileTransfer) {
connType = ConnType.fileTransfer;
@@ -2933,6 +2958,8 @@ class FFI {
connType = ConnType.viewCamera;
} else if (isPortForward) {
connType = ConnType.portForward;
} else if (isTerminal) {
connType = ConnType.terminal;
} else {
chatModel.resetClientMode();
connType = ConnType.defaultConn;
@@ -2953,6 +2980,7 @@ class FFI {
isViewCamera: isViewCamera,
isPortForward: isPortForward,
isRdp: isRdp,
isTerminal: isTerminal,
switchUuid: switchUuid ?? '',
forceRelay: forceRelay ?? false,
password: password ?? '',
@@ -3132,6 +3160,11 @@ class FFI {
Future<void> close({bool closeSession = true}) async {
closed = true;
chatModel.close();
// Close all terminal models
for (final model in _terminalModels.values) {
model.dispose();
}
_terminalModels.clear();
if (imageModel.image != null && !isWebDesktop) {
await setCanvasConfig(
sessionId,
@@ -3162,6 +3195,27 @@ class FFI {
Future<bool> invokeMethod(String method, [dynamic arguments]) async {
return await platformFFI.invokeMethod(method, arguments);
}
// Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
_terminalModels[terminalId] = model;
}
void unregisterTerminalModel(int terminalId) {
debugPrint('[FFI] Unregistering terminal model for terminal $terminalId');
_terminalModels.remove(terminalId);
}
void routeTerminalResponse(Map<String, dynamic> evt) {
final int terminalId = evt['terminal_id'] ?? 0;
// Route to specific terminal model if it exists
final model = _terminalModels[terminalId];
if (model != null) {
model.handleTerminalResponse(evt);
}
}
}
const kInvalidResolutionValue = -1;
@@ -3266,9 +3320,6 @@ 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

@@ -613,7 +613,13 @@ class ServerModel with ChangeNotifier {
void showLoginDialog(Client client) {
showClientDialog(
client,
client.isFileTransfer ? "File Connection" : "Screen Connection",
client.isFileTransfer
? "Transfer file"
: client.isViewCamera
? "View camera"
: client.isTerminal
? "Terminal"
: "Share screen",
'Do you accept?',
'android_new_connection_tip',
() => sendLoginResponse(client, false),
@@ -692,7 +698,7 @@ class ServerModel with ChangeNotifier {
void sendLoginResponse(Client client, bool res) async {
if (res) {
bind.cmLoginRes(connId: client.id, res: res);
if (!client.isFileTransfer) {
if (!client.isFileTransfer && !client.isTerminal) {
parent.target?.invokeMethod("start_capture");
}
parent.target?.invokeMethod("cancel_notification", client.id);
@@ -806,6 +812,7 @@ enum ClientType {
file,
camera,
portForward,
terminal,
}
class Client {
@@ -813,6 +820,7 @@ class Client {
bool authorized = false;
bool isFileTransfer = false;
bool isViewCamera = false;
bool isTerminal = false;
String portForward = "";
String name = "";
String peerId = ""; // peer user's id,show at app
@@ -839,6 +847,7 @@ class Client {
isFileTransfer = json['is_file_transfer'];
// TODO: no entry then default.
isViewCamera = json['is_view_camera'];
isTerminal = json['is_terminal'] ?? false;
portForward = json['port_forward'];
name = json['name'];
peerId = json['peer_id'];
@@ -861,6 +870,7 @@ class Client {
data['authorized'] = authorized;
data['is_file_transfer'] = isFileTransfer;
data['is_view_camera'] = isViewCamera;
data['is_terminal'] = isTerminal;
data['port_forward'] = portForward;
data['name'] = name;
data['peer_id'] = peerId;
@@ -883,6 +893,8 @@ class Client {
return ClientType.file;
} else if (isViewCamera) {
return ClientType.camera;
} else if (isTerminal) {
return ClientType.terminal;
} else if (portForward.isNotEmpty) {
return ClientType.portForward;
} else {

View File

@@ -0,0 +1,269 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
import 'model.dart';
import 'platform_model.dart';
class TerminalModel with ChangeNotifier {
final String id; // peer id
final FFI parent;
final int terminalId;
late final Terminal terminal;
late final TerminalController terminalController;
bool _terminalOpened = false;
bool get terminalOpened => _terminalOpened;
bool _disposed = false;
final _inputBuffer = <String>[];
Future<void> _handleInput(String data) async {
if (_terminalOpened) {
// Send user input to remote terminal
try {
await bind.sessionSendTerminalInput(
sessionId: parent.sessionId,
terminalId: terminalId,
data: data,
);
} catch (e) {
debugPrint('[TerminalModel] Error sending terminal input: $e');
}
} else {
debugPrint('[TerminalModel] Terminal not opened yet, buffering input');
_inputBuffer.add(data);
}
}
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = Terminal(maxLines: 10000);
terminalController = TerminalController();
// Setup terminal callbacks
terminal.onOutput = _handleInput;
terminal.onResize = (w, h, pw, ph) async {
// Validate all dimensions before using them
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
debugPrint(
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
if (_terminalOpened) {
// Notify remote terminal of resize
try {
await bind.sessionResizeTerminal(
sessionId: parent.sessionId,
terminalId: terminalId,
rows: h,
cols: w,
);
} catch (e) {
debugPrint('[TerminalModel] Error resizing terminal: $e');
}
}
} else {
debugPrint(
'[TerminalModel] Invalid terminal dimensions: ${w}x$h (pixel: ${pw}x$ph)');
}
};
}
void onReady() {
parent.dialogManager.dismissAll();
// Fire and forget - don't block onReady
openTerminal().catchError((e) {
debugPrint('[TerminalModel] Error opening terminal: $e');
});
}
Future<void> openTerminal() async {
if (_terminalOpened) return;
// Request the remote side to open a terminal with default shell
// The remote side will decide which shell to use based on its OS
// Get terminal dimensions, ensuring they are valid
int rows = 24;
int cols = 80;
if (terminal.viewHeight > 0) {
rows = terminal.viewHeight;
}
if (terminal.viewWidth > 0) {
cols = terminal.viewWidth;
}
debugPrint(
'[TerminalModel] Opening terminal $terminalId, sessionId: ${parent.sessionId}, size: ${cols}x$rows');
try {
await bind
.sessionOpenTerminal(
sessionId: parent.sessionId,
terminalId: terminalId,
rows: rows,
cols: cols,
)
.timeout(
const Duration(seconds: 5),
onTimeout: () {
throw TimeoutException(
'sessionOpenTerminal timed out after 5 seconds');
},
);
debugPrint('[TerminalModel] sessionOpenTerminal called successfully');
} catch (e) {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user
if (e is TimeoutException) {
terminal.write('Failed to open terminal: Connection timeout\r\n');
}
}
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {
await bind
.sessionCloseTerminal(
sessionId: parent.sessionId,
terminalId: terminalId,
)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
throw TimeoutException(
'sessionCloseTerminal timed out after 3 seconds');
},
);
debugPrint('[TerminalModel] sessionCloseTerminal called successfully');
} catch (e) {
debugPrint('[TerminalModel] Error calling sessionCloseTerminal: $e');
// Continue with cleanup even if close fails
}
_terminalOpened = false;
notifyListeners();
}
}
void handleTerminalResponse(Map<String, dynamic> evt) {
final String? type = evt['type'];
final int evtTerminalId = evt['terminal_id'] ?? 0;
// Only handle events for this terminal
if (evtTerminalId != terminalId) {
debugPrint(
'[TerminalModel] Ignoring event for terminal $evtTerminalId (not mine)');
return;
}
switch (type) {
case 'opened':
_handleTerminalOpened(evt);
break;
case 'data':
_handleTerminalData(evt);
break;
case 'closed':
_handleTerminalClosed(evt);
break;
case 'error':
_handleTerminalError(evt);
break;
}
}
void _handleTerminalOpened(Map<String, dynamic> evt) {
final bool success = evt['success'] ?? false;
final String message = evt['message'] ?? '';
final String? serviceId = evt['service_id'];
debugPrint(
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
if (success) {
_terminalOpened = true;
// Service ID is now saved on the Rust side in handle_terminal_response
// Process any buffered input
_processBufferedInputAsync().then((_) {
notifyListeners();
}).catchError((e) {
debugPrint('[TerminalModel] Error processing buffered input: $e');
notifyListeners();
});
} else {
terminal.write('Failed to open terminal: $message\r\n');
}
}
Future<void> _processBufferedInputAsync() async {
final buffer = List<String>.from(_inputBuffer);
_inputBuffer.clear();
for (final data in buffer) {
try {
await bind.sessionSendTerminalInput(
sessionId: parent.sessionId,
terminalId: terminalId,
data: data,
);
} catch (e) {
debugPrint('[TerminalModel] Error sending buffered input: $e');
}
}
}
void _handleTerminalData(Map<String, dynamic> evt) {
final data = evt['data'];
if (data != null) {
try {
String text = '';
if (data is String) {
// Try to decode as base64 first
try {
final bytes = base64Decode(data);
text = utf8.decode(bytes);
} catch (e) {
// If base64 decode fails, treat as plain text
text = data;
}
} else if (data is List) {
// Handle if data comes as byte array
text = utf8.decode(List<int>.from(data));
} else {
debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}');
return;
}
terminal.write(text);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
}
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0;
terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
_terminalOpened = false;
notifyListeners();
}
void _handleTerminalError(Map<String, dynamic> evt) {
final String message = evt['message'] ?? 'Unknown error';
terminal.write('\r\nTerminal error: $message\r\n');
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
// Terminal cleanup is handled server-side when service closes
super.dispose();
}
}

View File

@@ -17,6 +17,7 @@ enum WindowType {
FileTransfer,
ViewCamera,
PortForward,
Terminal,
Unknown
}
@@ -33,6 +34,8 @@ extension Index on int {
return WindowType.ViewCamera;
case 4:
return WindowType.PortForward;
case 5:
return WindowType.Terminal;
default:
return WindowType.Unknown;
}
@@ -61,6 +64,7 @@ class RustDeskMultiWindowManager {
final List<int> _fileTransferWindows = List.empty(growable: true);
final List<int> _viewCameraWindows = List.empty(growable: true);
final List<int> _portForwardWindows = List.empty(growable: true);
final List<int> _terminalWindows = List.empty(growable: true);
moveTabToNewWindow(int windowId, String peerId, String sessionId,
WindowType windowType) async {
@@ -343,6 +347,32 @@ class RustDeskMultiWindowManager {
);
}
Future<MultiWindowCallResult> newTerminal(
String remoteId, {
String? password,
bool? isSharedPassword,
bool? forceRelay,
String? connToken,
}) async {
// Terminal windows should always create new windows, not reuse
// This avoids the MissingPluginException when trying to invoke
// new_terminal on an inactive window
var params = {
"type": WindowType.Terminal.index,
"id": remoteId,
"password": password,
"forceRelay": forceRelay,
"isSharedPassword": isSharedPassword,
"connToken": connToken,
};
final msg = jsonEncode(params);
// Always create a new window for terminal
final windowId = await newSessionWindow(
WindowType.Terminal, remoteId, msg, _terminalWindows, false);
return MultiWindowCallResult(windowId, null);
}
Future<MultiWindowCallResult> call(
WindowType type, String methodName, dynamic args) async {
final wnds = _findWindowsByType(type);
@@ -373,6 +403,8 @@ class RustDeskMultiWindowManager {
return _viewCameraWindows;
case WindowType.PortForward:
return _portForwardWindows;
case WindowType.Terminal:
return _terminalWindows;
case WindowType.Unknown:
break;
}
@@ -395,6 +427,8 @@ class RustDeskMultiWindowManager {
case WindowType.PortForward:
_portForwardWindows.clear();
break;
case WindowType.Terminal:
_terminalWindows.clear();
case WindowType.Unknown:
break;
}

View File

@@ -81,6 +81,7 @@ class RustdeskImpl {
required bool isViewCamera,
required bool isPortForward,
required bool isRdp,
required bool isTerminal,
required String switchUuid,
required bool forceRelay,
required String password,
@@ -94,7 +95,8 @@ class RustdeskImpl {
'password': password,
'is_shared_password': isSharedPassword,
'isFileTransfer': isFileTransfer,
'isViewCamera': isViewCamera
'isViewCamera': isViewCamera,
'isTerminal': isTerminal
})
]);
}
@@ -1911,5 +1913,63 @@ class RustdeskImpl {
throw UnimplementedError("sessionTakeScreenshot");
}
Future<void> sessionOpenTerminal(
{required UuidValue sessionId,
required int terminalId,
required int rows,
required int cols,
dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
'open_terminal',
jsonEncode({
'terminal_id': terminalId,
'rows': rows,
'cols': cols,
})
]));
}
Future<void> sessionSendTerminalInput(
{required UuidValue sessionId,
required int terminalId,
required String data,
dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
'send_terminal_input',
jsonEncode({
'terminal_id': terminalId,
'data': data,
})
]));
}
Future<void> sessionResizeTerminal(
{required UuidValue sessionId,
required int terminalId,
required int rows,
required int cols,
dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
'resize_terminal',
jsonEncode({
'terminal_id': terminalId,
'rows': rows,
'cols': cols,
})
]));
}
Future<void> sessionCloseTerminal(
{required UuidValue sessionId,
required int terminalId,
dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
'close_terminal',
jsonEncode({
'terminal_id': terminalId,
})
]));
}
void dispose() {}
}

View File

@@ -10,6 +10,11 @@ PODS:
- flutter_custom_cursor (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- FMDB (2.7.12):
- FMDB/standard (= 2.7.12)
- FMDB/Core (2.7.12)
- FMDB/standard (2.7.12):
- FMDB/Core
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
@@ -17,9 +22,9 @@ PODS:
- FlutterMacOS
- screen_retriever (0.0.1):
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- sqflite (0.0.2):
- FlutterMacOS
- FMDB (>= 2.7.5)
- texture_rgba_renderer (0.0.1):
- FlutterMacOS
- uni_links_desktop (0.0.1):
@@ -46,7 +51,7 @@ DEPENDENCIES:
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`)
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`)
- texture_rgba_renderer (from `Flutter/ephemeral/.symlinks/plugins/texture_rgba_renderer/macos`)
- uni_links_desktop (from `Flutter/ephemeral/.symlinks/plugins/uni_links_desktop/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
@@ -55,6 +60,10 @@ DEPENDENCIES:
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
- window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`)
SPEC REPOS:
trunk:
- FMDB
EXTERNAL SOURCES:
desktop_drop:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
@@ -75,7 +84,7 @@ EXTERNAL SOURCES:
screen_retriever:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
sqflite:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin
:path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos
texture_rgba_renderer:
:path: Flutter/ephemeral/.symlinks/plugins/texture_rgba_renderer/macos
uni_links_desktop:
@@ -92,24 +101,25 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_size/macos
SPEC CHECKSUMS:
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
flutter_custom_cursor: 629957115075c672287bd0fa979d863ccf6024f7
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
device_info_plus: b0fafc687fb901e2af612763340f1b0d4352f8e5
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flutter_custom_cursor: 37e588711a2746f5cf48adb58b582cacff11c0c6
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
texture_rgba_renderer: cbed959a3c127122194a364e14b8577bd62dc8f2
uni_links_desktop: 45900fb319df48fcdea2df0756e9c2626696b026
url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
FMDB: 728731dd336af3936ce00f91d9d8495f5718a0e6
package_info_plus: 122abb51244f66eead59ce7c9c200d6b53111779
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
screen_retriever: 4f97c103641aab8ce183fa5af3b87029df167936
sqflite: c73556b2499b92f0b6e6946abe4a4084510cdf90
texture_rgba_renderer: 6661f577ea5d4990e964c7e3840e544ac798e6da
uni_links_desktop: 34322c2646e4c9abc69b62e1865f9782d2850ba2
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,8 @@ dependencies:
device_info_plus: ^9.1.0
qr_flutter: ^4.1.0
extended_text: 14.0.0
xterm: 4.0.0
sqflite: 2.2.0
dev_dependencies:
icons_launcher: ^2.0.4
@@ -118,7 +120,8 @@ dev_dependencies:
dependency_overrides:
intl: ^0.19.0
flutter_plugin_android_lifecycle: 2.0.17
# rerun: flutter pub run flutter_launcher_icons
flutter_icons:
image_path: "../res/icon.png"
@@ -193,4 +196,3 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages

View File

@@ -1,9 +0,0 @@
assets
js/src/gen_js_from_hbb.ts
js/src/message.ts
js/src/rendezvous.ts
ogvjs*
libopus.js
libopus.wasm
yuv-canvas*
node_modules

View File

@@ -1 +0,0 @@
v1 is not compatible with current Flutter source code.

View File

@@ -1,183 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Remote Desktop.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="RustDesk">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<title>RustDesk</title>
<link rel="manifest" href="manifest.json">
<script src="ogvjs-1.8.6/ogv.js"></script>
<script type="module" crossorigin src="js/dist/index.js"></script>
<link rel="modulepreload" href="js/dist/vendor.js">
<script src="yuv-canvas-1.2.6.js"></script>
<style>
.loading {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.loader {
border: 16px solid #f3f3f3;
border-radius: 50%;
border: 15px solid;
border-top: 16px solid #024eff;
border-right: 16px solid white;
border-bottom: 16px solid #024eff;
border-left: 16px solid white;
width: 120px;
height: 120px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="loading">
<div class="loader"></div>
</div>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script>
var serviceWorkerVersion = null;
var scriptLoaded = false;
function loadMainDartJs() {
if (scriptLoaded) {
return;
}
scriptLoaded = true;
var scriptTag = document.createElement('script');
scriptTag.src = 'main.dart.js';
scriptTag.type = 'application/javascript';
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
// Service workers are supported. Use them.
window.addEventListener('load', function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing || reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plaint <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
});
} else {
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script>
<script src="libs/firebase-app.js?8.10.1"></script>
<script src="libs/firebase-analytics.js?8.10.1"></script>
<script>
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AIzaSyCgehIZk1aFP0E7wZtYRRqrfvNiNAF39-A",
authDomain: "rustdesk.firebaseapp.com",
databaseURL: "https://rustdesk.firebaseio.com",
projectId: "rustdesk",
storageBucket: "rustdesk.appspot.com",
messagingSenderId: "768133699366",
appId: "1:768133699366:web:d50faf0792cb208d7993e7",
measurementId: "G-9PEH85N6ZQ"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
firebase.analytics();
</script>
</body>
</html>

View File

@@ -1 +0,0 @@
* text=auto

View File

@@ -1,9 +0,0 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
*log
ogvjs
.vscode
.yarn

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env python3
import re
import os
import glob
from tabnanny import check
def pad_start(s, n, c = ' '):
if len(s) >= n:
return s
return c * (n - len(s)) + s
def safe_unicode(s):
res = ""
for c in s:
res += r"\u{}".format(pad_start(hex(ord(c))[2:], 4, '0'))
return res
def main():
print('export const LANGS = {')
for fn in glob.glob('../../../src/lang/*'):
lang = os.path.basename(fn)[:-3]
if lang == 'template': continue
print(' %s: {'%lang)
for ln in open(fn, encoding='utf-8'):
ln = ln.strip()
if ln.startswith('("'):
toks = ln.split('", "')
assert(len(toks) == 2)
a = toks[0][2:]
b = toks[1][:-3]
print(' "%s": "%s",'%(safe_unicode(a), safe_unicode(b)))
print(' },')
print('}')
check_if_retry = ['', False]
KEY_MAP = ['', False]
for ln in open('../../../src/client.rs', encoding='utf-8'):
ln = ln.strip()
if 'check_if_retry' in ln:
check_if_retry[1] = True
continue
if ln.startswith('}') and check_if_retry[1]:
check_if_retry[1] = False
continue
if check_if_retry[1]:
ln = removeComment(ln)
check_if_retry[0] += ln + '\n'
if 'KEY_MAP' in ln:
KEY_MAP[1] = True
continue
if '.collect' in ln and KEY_MAP[1]:
KEY_MAP[1] = False
continue
if KEY_MAP[1] and ln.startswith('('):
ln = removeComment(ln)
toks = ln.split('", Key::')
assert(len(toks) == 2)
a = toks[0][2:]
b = toks[1].replace('ControlKey(ControlKey::', '').replace("Chr('", '').replace("' as _)),", '').replace(')),', '')
KEY_MAP[0] += ' "%s": "%s",\n'%(a, b)
print()
print('export function checkIfRetry(msgtype: string, title: string, text: string, retry_for_relay: boolean) {')
print(' return %s'%check_if_retry[0].replace('to_lowercase', 'toLowerCase').replace('contains', 'indexOf').replace('!', '').replace('")', '") < 0'))
print(';}')
print()
print('export const KEY_MAP: any = {')
print(KEY_MAP[0])
print('}')
for ln in open('../../../Cargo.toml', encoding='utf-8'):
if ln.startswith('version ='):
print('export const ' + ln)
def removeComment(ln):
return re.sub('\s+\/\/.*$', '', ln)
main()

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg?v2" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="ogvjs-1.8.6/ogv.js"></script>
<script src="./yuv-canvas-1.2.6.js"></script>
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
{
"name": "web_hbb",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "./gen_js_from_hbb.py > src/gen_js_from_hbb.ts && ./ts_proto.py && tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^4.4.4",
"vite": "^2.7.2"
},
"dependencies": {
"fast-sha256": "^1.3.0",
"libsodium": "^0.7.9",
"libsodium-wrappers": "^0.7.9",
"pcm-player": "^0.0.11",
"ts-proto": "^1.101.0",
"wasm-feature-detect": "^1.2.11",
"zstddec": "^0.0.2"
}
}

View File

@@ -1,43 +0,0 @@
// example: https://github.com/rgov/js-theora-decoder/blob/main/index.html
// https://github.com/brion/ogv.js/releases, yarn add has no simd
// dev: copy decoder files from node/ogv/dist/* to project dir
// dist: .... to dist
/*
OGVDemuxerOggW: 'ogv-demuxer-ogg-wasm.js',
OGVDemuxerWebMW: 'ogv-demuxer-webm-wasm.js',
OGVDecoderAudioOpusW: 'ogv-decoder-audio-opus-wasm.js',
OGVDecoderAudioVorbisW: 'ogv-decoder-audio-vorbis-wasm.js',
OGVDecoderVideoTheoraW: 'ogv-decoder-video-theora-wasm.js',
OGVDecoderVideoVP8W: 'ogv-decoder-video-vp8-wasm.js',
OGVDecoderVideoVP8MTW: 'ogv-decoder-video-vp8-mt-wasm.js',
OGVDecoderVideoVP9W: 'ogv-decoder-video-vp9-wasm.js',
OGVDecoderVideoVP9SIMDW: 'ogv-decoder-video-vp9-simd-wasm.js',
OGVDecoderVideoVP9MTW: 'ogv-decoder-video-vp9-mt-wasm.js',
OGVDecoderVideoVP9SIMDMTW: 'ogv-decoder-video-vp9-simd-mt-wasm.js',
OGVDecoderVideoAV1W: 'ogv-decoder-video-av1-wasm.js',
OGVDecoderVideoAV1SIMDW: 'ogv-decoder-video-av1-simd-wasm.js',
OGVDecoderVideoAV1MTW: 'ogv-decoder-video-av1-mt-wasm.js',
OGVDecoderVideoAV1SIMDMTW: 'ogv-decoder-video-av1-simd-mt-wasm.js',
*/
import { simd } from "wasm-feature-detect";
export async function loadVp9(callback) {
// Multithreading is used only if `options.threading` is true.
// This requires browser support for the new `SharedArrayBuffer` and `Atomics` APIs,
// currently available in Firefox and Chrome with experimental flags enabled.
// 所有主流浏览器均默认于2018年1月5日禁用SharedArrayBuffer
const isSIMD = await simd();
console.log('isSIMD: ' + isSIMD);
window.OGVLoader.loadClass(
isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W",
(videoCodecClass) => {
window.videoCodecClass = videoCodecClass;
videoCodecClass({ videoFormat: {} }).then((decoder) => {
decoder.init(() => {
callback(decoder);
})
})
},
{ worker: true, threading: true }
);
}

View File

@@ -1,77 +0,0 @@
import * as zstd from "zstddec";
import { KeyEvent, controlKeyFromJSON, ControlKey } from "./message";
import { KEY_MAP, LANGS } from "./gen_js_from_hbb";
let decompressor: zstd.ZSTDDecoder;
export async function initZstd() {
const tmp = new zstd.ZSTDDecoder();
await tmp.init();
console.log("zstd ready");
decompressor = tmp;
}
export async function decompress(compressedArray: Uint8Array) {
const MAX = 1024 * 1024 * 64;
const MIN = 1024 * 1024;
let n = 30 * compressedArray.length;
if (n > MAX) {
n = MAX;
}
if (n < MIN) {
n = MIN;
}
try {
if (!decompressor) {
await initZstd();
}
return decompressor.decode(compressedArray, n);
} catch (e) {
console.error("decompress failed: " + e);
return undefined;
}
}
const LANG = getLang();
export function translate(locale: string, text: string): string {
const lang = LANG || locale.substring(locale.length - 2).toLowerCase();
let en = LANGS.en as any;
let dict = (LANGS as any)[lang];
if (!dict) dict = en;
let res = dict[text];
if (!res && lang != "en") res = en[text];
return res || text;
}
const zCode = "z".charCodeAt(0);
const aCode = "a".charCodeAt(0);
export function mapKey(name: string, isDesktop: Boolean) {
const tmp = KEY_MAP[name] || name;
if (tmp.length == 1) {
const chr = tmp.charCodeAt(0);
if (!isDesktop && (chr > zCode || chr < aCode))
return KeyEvent.fromPartial({ unicode: chr });
else return KeyEvent.fromPartial({ chr });
}
const control_key = controlKeyFromJSON(tmp);
if (control_key == ControlKey.UNRECOGNIZED) {
console.error("Unknown control key " + tmp);
}
return KeyEvent.fromPartial({ control_key });
}
export async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
function getLang(): string {
try {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
return urlParams.get("lang") || "";
} catch (e) {
return "";
}
}

View File

@@ -1,773 +0,0 @@
import Websock from "./websock";
import * as message from "./message.js";
import * as rendezvous from "./rendezvous.js";
import { loadVp9 } from "./codec";
import * as sha256 from "fast-sha256";
import * as globals from "./globals";
import { decompress, mapKey, sleep } from "./common";
const PORT = 21116;
const HOSTS = [
"rs-sg.rustdesk.com",
"rs-cn.rustdesk.com",
"rs-us.rustdesk.com",
];
let HOST = localStorage.getItem("rendezvous-server") || HOSTS[0];
const SCHEMA = "ws://";
type MsgboxCallback = (type: string, title: string, text: string) => void;
type DrawCallback = (data: Uint8Array) => void;
//const cursorCanvas = document.createElement("canvas");
export default class Connection {
_msgs: any[];
_ws: Websock | undefined;
_interval: any;
_id: string;
_hash: message.Hash | undefined;
_msgbox: MsgboxCallback;
_draw: DrawCallback;
_peerInfo: message.PeerInfo | undefined;
_firstFrame: Boolean | undefined;
_videoDecoder: any;
_password: Uint8Array | undefined;
_options: any;
_videoTestSpeed: number[];
//_cursors: { [name: number]: any };
constructor() {
this._msgbox = globals.msgbox;
this._draw = globals.draw;
this._msgs = [];
this._id = "";
this._videoTestSpeed = [0, 0];
//this._cursors = {};
}
async start(id: string) {
try {
await this._start(id);
} catch (e: any) {
this.msgbox(
"error",
"Connection Error",
e.type == "close" ? "Reset by the peer" : String(e)
);
}
}
async _start(id: string) {
if (!this._options) {
this._options = globals.getPeers()[id] || {};
}
if (!this._password) {
const p = this.getOption("password");
if (p) {
try {
this._password = Uint8Array.from(JSON.parse("[" + p + "]"));
} catch (e) {
console.error(e);
}
}
}
this._interval = setInterval(() => {
while (this._msgs.length) {
this._ws?.sendMessage(this._msgs[0]);
this._msgs.splice(0, 1);
}
}, 1);
this.loadVideoDecoder();
const uri = getDefaultUri();
const ws = new Websock(uri, true);
this._ws = ws;
this._id = id;
console.log(
new Date() + ": Connecting to rendezvous server: " + uri + ", for " + id
);
await ws.open();
console.log(new Date() + ": Connected to rendezvous server");
const conn_type = rendezvous.ConnType.DEFAULT_CONN;
const nat_type = rendezvous.NatType.SYMMETRIC;
const punch_hole_request = rendezvous.PunchHoleRequest.fromPartial({
id,
licence_key: localStorage.getItem("key") || undefined,
conn_type,
nat_type,
token: localStorage.getItem("access_token") || undefined,
});
ws.sendRendezvous({ punch_hole_request });
const msg = (await ws.next()) as rendezvous.RendezvousMessage;
ws.close();
console.log(new Date() + ": Got relay response");
const phr = msg.punch_hole_response;
const rr = msg.relay_response;
if (phr) {
if (phr?.other_failure) {
this.msgbox("error", "Error", phr?.other_failure);
return;
}
if (phr.failure != rendezvous.PunchHoleResponse_Failure.UNRECOGNIZED) {
switch (phr?.failure) {
case rendezvous.PunchHoleResponse_Failure.ID_NOT_EXIST:
this.msgbox("error", "Error", "ID does not exist");
break;
case rendezvous.PunchHoleResponse_Failure.OFFLINE:
this.msgbox("error", "Error", "Remote desktop is offline");
break;
case rendezvous.PunchHoleResponse_Failure.LICENSE_MISMATCH:
this.msgbox("error", "Error", "Key mismatch");
break;
case rendezvous.PunchHoleResponse_Failure.LICENSE_OVERUSE:
this.msgbox("error", "Error", "Key overuse");
break;
}
}
} else if (rr) {
if (!rr.version) {
this.msgbox("error", "Error", "Remote version is low, not support web");
return;
}
await this.connectRelay(rr);
}
}
async connectRelay(rr: rendezvous.RelayResponse) {
const pk = rr.pk;
let uri = rr.relay_server;
if (uri) {
uri = getrUriFromRs(uri, true, 2);
} else {
uri = getDefaultUri(true);
}
const uuid = rr.uuid;
console.log(new Date() + ": Connecting to relay server: " + uri);
const ws = new Websock(uri, false);
await ws.open();
console.log(new Date() + ": Connected to relay server");
this._ws = ws;
const request_relay = rendezvous.RequestRelay.fromPartial({
licence_key: localStorage.getItem("key") || undefined,
uuid,
});
ws.sendRendezvous({ request_relay });
const secure = (await this.secure(pk)) || false;
globals.pushEvent("connection_ready", { secure, direct: false });
await this.msgLoop();
}
async secure(pk: Uint8Array | undefined) {
if (pk) {
const RS_PK = "OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=";
try {
pk = await globals.verify(pk, localStorage.getItem("key") || RS_PK);
if (pk) {
const idpk = message.IdPk.decode(pk);
if (idpk.id == this._id) {
pk = idpk.pk;
}
}
if (pk?.length != 32) {
pk = undefined;
}
} catch (e) {
console.error(e);
pk = undefined;
}
if (!pk)
console.error(
"Handshake failed: invalid public key from rendezvous server"
);
}
if (!pk) {
// send an empty message out in case server is setting up secure and waiting for first message
const public_key = message.PublicKey.fromPartial({});
this._ws?.sendMessage({ public_key });
return;
}
const msg = (await this._ws?.next()) as message.Message;
let signedId: any = msg?.signed_id;
if (!signedId) {
console.error("Handshake failed: invalid message type");
const public_key = message.PublicKey.fromPartial({});
this._ws?.sendMessage({ public_key });
return;
}
try {
signedId = await globals.verify(signedId.id, Uint8Array.from(pk!));
} catch (e) {
console.error(e);
// fall back to non-secure connection in case pk mismatch
console.error("pk mismatch, fall back to non-secure");
const public_key = message.PublicKey.fromPartial({});
this._ws?.sendMessage({ public_key });
return;
}
const idpk = message.IdPk.decode(signedId);
const id = idpk.id;
const theirPk = idpk.pk;
if (id != this._id!) {
console.error("Handshake failed: sign failure");
const public_key = message.PublicKey.fromPartial({});
this._ws?.sendMessage({ public_key });
return;
}
if (theirPk.length != 32) {
console.error(
"Handshake failed: invalid public box key length from peer"
);
const public_key = message.PublicKey.fromPartial({});
this._ws?.sendMessage({ public_key });
return;
}
const [mySk, asymmetric_value] = globals.genBoxKeyPair();
const secret_key = globals.genSecretKey();
const symmetric_value = globals.seal(secret_key, theirPk, mySk);
const public_key = message.PublicKey.fromPartial({
asymmetric_value,
symmetric_value,
});
this._ws?.sendMessage({ public_key });
this._ws?.setSecretKey(secret_key);
console.log("secured");
return true;
}
async msgLoop() {
while (true) {
const msg = (await this._ws?.next()) as message.Message;
if (msg?.hash) {
this._hash = msg?.hash;
if (!this._password)
this.msgbox("input-password", "Password Required", "");
this.login();
} else if (msg?.test_delay) {
const test_delay = msg?.test_delay;
console.log(test_delay);
if (!test_delay.from_client) {
this._ws?.sendMessage({ test_delay });
}
} else if (msg?.login_response) {
const r = msg?.login_response;
if (r.error) {
if (r.error == "Wrong Password") {
this._password = undefined;
this.msgbox(
"re-input-password",
r.error,
"Do you want to enter again?"
);
} else {
this.msgbox("error", "Login Error", r.error);
}
} else if (r.peer_info) {
this.handlePeerInfo(r.peer_info);
}
} else if (msg?.video_frame) {
this.handleVideoFrame(msg?.video_frame!);
} else if (msg?.clipboard) {
const cb = msg?.clipboard;
if (cb.compress) {
const c = await decompress(cb.content);
if (!c) continue;
cb.content = c;
}
try {
globals.copyToClipboard(new TextDecoder().decode(cb.content));
} catch (e) {
console.error(e);
}
// globals.pushEvent("clipboard", cb);
} else if (msg?.cursor_data) {
const cd = msg?.cursor_data;
const c = await decompress(cd.colors);
if (!c) continue;
cd.colors = c;
globals.pushEvent("cursor_data", cd);
/*
let ctx = cursorCanvas.getContext("2d");
cursorCanvas.width = cd.width;
cursorCanvas.height = cd.height;
let imgData = new ImageData(
new Uint8ClampedArray(c),
cd.width,
cd.height
);
ctx?.clearRect(0, 0, cd.width, cd.height);
ctx?.putImageData(imgData, 0, 0);
let url = cursorCanvas.toDataURL();
const img = document.createElement("img");
img.src = url;
this._cursors[cd.id] = img;
//cursorCanvas.width /= 2.;
//cursorCanvas.height /= 2.;
//ctx?.drawImage(img, cursorCanvas.width, cursorCanvas.height);
url = cursorCanvas.toDataURL();
document.body.style.cursor =
"url(" + url + ")" + cd.hotx + " " + cd.hoty + ", default";
console.log(document.body.style.cursor);
*/
} else if (msg?.cursor_id) {
globals.pushEvent("cursor_id", { id: msg?.cursor_id });
} else if (msg?.cursor_position) {
globals.pushEvent("cursor_position", msg?.cursor_position);
} else if (msg?.misc) {
if (!this.handleMisc(msg?.misc)) break;
} else if (msg?.audio_frame) {
globals.playAudio(msg?.audio_frame.data);
}
}
}
msgbox(type_: string, title: string, text: string) {
this._msgbox?.(type_, title, text);
}
draw(frame: any) {
this._draw?.(frame);
globals.draw(frame);
}
close() {
this._msgs = [];
clearInterval(this._interval);
this._ws?.close();
this._videoDecoder?.close();
}
refresh() {
const misc = message.Misc.fromPartial({ refresh_video: true });
this._ws?.sendMessage({ misc });
}
setMsgbox(callback: MsgboxCallback) {
this._msgbox = callback;
}
setDraw(callback: DrawCallback) {
this._draw = callback;
}
login(password: string | undefined = undefined) {
if (password) {
const salt = this._hash?.salt;
let p = hash([password, salt!]);
this._password = p;
const challenge = this._hash?.challenge;
p = hash([p, challenge!]);
this.msgbox("connecting", "Connecting...", "Logging in...");
this._sendLoginMessage(p);
} else {
let p = this._password;
if (p) {
const challenge = this._hash?.challenge;
p = hash([p, challenge!]);
}
this._sendLoginMessage(p);
}
}
async reconnect() {
this.close();
await this.start(this._id);
}
_sendLoginMessage(password: Uint8Array | undefined = undefined) {
const login_request = message.LoginRequest.fromPartial({
username: this._id!,
my_id: "web", // to-do
my_name: "web", // to-do
password,
option: this.getOptionMessage(),
video_ack_required: true,
});
this._ws?.sendMessage({ login_request });
}
getOptionMessage(): message.OptionMessage | undefined {
let n = 0;
const msg = message.OptionMessage.fromPartial({});
const q = this.getImageQualityEnum(this.getImageQuality(), true);
const yes = message.OptionMessage_BoolOption.Yes;
if (q != undefined) {
msg.image_quality = q;
n += 1;
}
if (this._options["show-remote-cursor"]) {
msg.show_remote_cursor = yes;
n += 1;
}
if (this._options["lock-after-session-end"]) {
msg.lock_after_session_end = yes;
n += 1;
}
if (this._options["privacy-mode"]) {
msg.privacy_mode = yes;
n += 1;
}
if (this._options["disable-audio"]) {
msg.disable_audio = yes;
n += 1;
}
if (this._options["disable-clipboard"]) {
msg.disable_clipboard = yes;
n += 1;
}
return n > 0 ? msg : undefined;
}
sendVideoReceived() {
const misc = message.Misc.fromPartial({ video_received: true });
this._ws?.sendMessage({ misc });
}
handleVideoFrame(vf: message.VideoFrame) {
if (!this._firstFrame) {
this.msgbox("", "", "");
this._firstFrame = true;
}
if (vf.vp9s) {
const dec = this._videoDecoder;
var tm = new Date().getTime();
var i = 0;
const n = vf.vp9s?.frames.length;
vf.vp9s.frames.forEach((f) => {
dec.processFrame(f.data.slice(0).buffer, (ok: any) => {
i++;
if (i == n) this.sendVideoReceived();
if (ok && dec.frameBuffer && n == i) {
this.draw(dec.frameBuffer);
const now = new Date().getTime();
var elapsed = now - tm;
this._videoTestSpeed[1] += elapsed;
this._videoTestSpeed[0] += 1;
if (this._videoTestSpeed[0] >= 30) {
console.log(
"video decoder: " +
parseInt(
"" + this._videoTestSpeed[1] / this._videoTestSpeed[0]
)
);
this._videoTestSpeed = [0, 0];
}
}
});
});
}
}
handlePeerInfo(pi: message.PeerInfo) {
this._peerInfo = pi;
if (pi.displays.length == 0) {
this.msgbox("error", "Remote Error", "No Display");
return;
}
this.msgbox("success", "Successful", "Connected, waiting for image...");
globals.pushEvent("peer_info", pi);
const p = this.shouldAutoLogin();
if (p) this.inputOsPassword(p);
const username = this.getOption("info")?.username;
if (username && !pi.username) pi.username = username;
this.setOption("info", pi);
if (this.getRemember()) {
if (this._password?.length) {
const p = this._password.toString();
if (p != this.getOption("password")) {
this.setOption("password", p);
console.log("remember password of " + this._id);
}
}
} else {
this.setOption("password", undefined);
}
}
shouldAutoLogin(): string {
const l = this.getOption("lock-after-session-end");
const a = !!this.getOption("auto-login");
const p = this.getOption("os-password");
if (p && l && a) {
return p;
}
return "";
}
handleMisc(misc: message.Misc) {
if (misc.audio_format) {
globals.initAudio(
misc.audio_format.channels,
misc.audio_format.sample_rate
);
} else if (misc.chat_message) {
globals.pushEvent("chat", { text: misc.chat_message.text });
} else if (misc.permission_info) {
const p = misc.permission_info;
console.info("Change permission " + p.permission + " -> " + p.enabled);
let name;
switch (p.permission) {
case message.PermissionInfo_Permission.Keyboard:
name = "keyboard";
break;
case message.PermissionInfo_Permission.Clipboard:
name = "clipboard";
break;
case message.PermissionInfo_Permission.Audio:
name = "audio";
break;
default:
return;
}
globals.pushEvent("permission", { [name]: p.enabled });
} else if (misc.switch_display) {
this.loadVideoDecoder();
globals.pushEvent("switch_display", misc.switch_display);
} else if (misc.close_reason) {
this.msgbox("error", "Connection Error", misc.close_reason);
this.close();
return false;
}
return true;
}
getRemember(): Boolean {
return this._options["remember"] || false;
}
setRemember(v: Boolean) {
this.setOption("remember", v);
}
getOption(name: string): any {
return this._options[name];
}
setOption(name: string, value: any) {
if (value == undefined) {
delete this._options[name];
} else {
this._options[name] = value;
}
this._options["tm"] = new Date().getTime();
const peers = globals.getPeers();
peers[this._id] = this._options;
localStorage.setItem("peers", JSON.stringify(peers));
}
inputKey(
name: string,
down: boolean,
press: boolean,
alt: Boolean,
ctrl: Boolean,
shift: Boolean,
command: Boolean
) {
const key_event = mapKey(name, globals.isDesktop());
if (!key_event) return;
if (alt && (name == "VK_MENU" || name == "RAlt")) {
alt = false;
}
if (ctrl && (name == "VK_CONTROL" || name == "RControl")) {
ctrl = false;
}
if (shift && (name == "VK_SHIFT" || name == "RShift")) {
shift = false;
}
if (command && (name == "Meta" || name == "RWin")) {
command = false;
}
key_event.down = down;
key_event.press = press;
key_event.modifiers = this.getMod(alt, ctrl, shift, command);
this._ws?.sendMessage({ key_event });
}
ctrlAltDel() {
const key_event = message.KeyEvent.fromPartial({ down: true });
if (this._peerInfo?.platform == "Windows") {
key_event.control_key = message.ControlKey.CtrlAltDel;
} else {
key_event.control_key = message.ControlKey.Delete;
key_event.modifiers = this.getMod(true, true, false, false);
}
this._ws?.sendMessage({ key_event });
}
inputString(seq: string) {
const key_event = message.KeyEvent.fromPartial({ seq });
this._ws?.sendMessage({ key_event });
}
switchDisplay(display: number) {
const switch_display = message.SwitchDisplay.fromPartial({ display });
const misc = message.Misc.fromPartial({ switch_display });
this._ws?.sendMessage({ misc });
}
async inputOsPassword(seq: string) {
this.inputMouse();
await sleep(50);
this.inputMouse(0, 3, 3);
await sleep(50);
this.inputMouse(1 | (1 << 3));
this.inputMouse(2 | (1 << 3));
await sleep(1200);
const key_event = message.KeyEvent.fromPartial({ press: true, seq });
this._ws?.sendMessage({ key_event });
}
lockScreen() {
const key_event = message.KeyEvent.fromPartial({
down: true,
control_key: message.ControlKey.LockScreen,
});
this._ws?.sendMessage({ key_event });
}
getMod(alt: Boolean, ctrl: Boolean, shift: Boolean, command: Boolean) {
const mod: message.ControlKey[] = [];
if (alt) mod.push(message.ControlKey.Alt);
if (ctrl) mod.push(message.ControlKey.Control);
if (shift) mod.push(message.ControlKey.Shift);
if (command) mod.push(message.ControlKey.Meta);
return mod;
}
inputMouse(
mask: number = 0,
x: number = 0,
y: number = 0,
alt: Boolean = false,
ctrl: Boolean = false,
shift: Boolean = false,
command: Boolean = false
) {
const mouse_event = message.MouseEvent.fromPartial({
mask,
x,
y,
modifiers: this.getMod(alt, ctrl, shift, command),
});
this._ws?.sendMessage({ mouse_event });
}
toggleOption(name: string) {
const v = !this._options[name];
const option = message.OptionMessage.fromPartial({});
const v2 = v
? message.OptionMessage_BoolOption.Yes
: message.OptionMessage_BoolOption.No;
switch (name) {
case "show-remote-cursor":
option.show_remote_cursor = v2;
break;
case "disable-audio":
option.disable_audio = v2;
break;
case "disable-clipboard":
option.disable_clipboard = v2;
break;
case "lock-after-session-end":
option.lock_after_session_end = v2;
break;
case "privacy-mode":
option.privacy_mode = v2;
break;
case "block-input":
option.block_input = message.OptionMessage_BoolOption.Yes;
break;
case "unblock-input":
option.block_input = message.OptionMessage_BoolOption.No;
break;
default:
return;
}
if (name.indexOf("block-input") < 0) this.setOption(name, v);
const misc = message.Misc.fromPartial({ option });
this._ws?.sendMessage({ misc });
}
getImageQuality() {
return this.getOption("image-quality");
}
getImageQualityEnum(
value: string,
ignoreDefault: Boolean
): message.ImageQuality | undefined {
switch (value) {
case "low":
return message.ImageQuality.Low;
case "best":
return message.ImageQuality.Best;
case "balanced":
return ignoreDefault ? undefined : message.ImageQuality.Balanced;
default:
return undefined;
}
}
setImageQuality(value: string) {
this.setOption("image-quality", value);
const image_quality = this.getImageQualityEnum(value, false);
if (image_quality == undefined) return;
const option = message.OptionMessage.fromPartial({ image_quality });
const misc = message.Misc.fromPartial({ option });
this._ws?.sendMessage({ misc });
}
loadVideoDecoder() {
this._videoDecoder?.close();
loadVp9((decoder: any) => {
this._videoDecoder = decoder;
console.log("vp9 loaded");
console.log(decoder);
});
}
}
function testDelay() {
var nearest = "";
HOSTS.forEach((host) => {
const now = new Date().getTime();
new Websock(getrUriFromRs(host), true).open().then(() => {
console.log("latency of " + host + ": " + (new Date().getTime() - now));
if (!nearest) {
HOST = host;
localStorage.setItem("rendezvous-server", host);
}
});
});
}
testDelay();
function getDefaultUri(isRelay: Boolean = false): string {
const host = localStorage.getItem("custom-rendezvous-server");
return getrUriFromRs(host || HOST, isRelay);
}
function getrUriFromRs(
uri: string,
isRelay: Boolean = false,
roffset: number = 0
): string {
if (uri.indexOf(":") > 0) {
const tmp = uri.split(":");
const port = parseInt(tmp[1]);
uri = tmp[0] + ":" + (port + (isRelay ? roffset || 3 : 2));
} else {
uri += ":" + (PORT + (isRelay ? 3 : 2));
}
return SCHEMA + uri;
}
function hash(datas: (string | Uint8Array)[]): Uint8Array {
const hasher = new sha256.Hash();
datas.forEach((data) => {
if (typeof data == "string") {
data = new TextEncoder().encode(data);
}
return hasher.update(data);
});
return hasher.digest();
}

View File

@@ -1,383 +0,0 @@
import Connection from "./connection";
import _sodium from "libsodium-wrappers";
import { CursorData } from "./message";
import { loadVp9 } from "./codec";
import { checkIfRetry, version } from "./gen_js_from_hbb";
import { initZstd, translate } from "./common";
import PCMPlayer from "pcm-player";
window.curConn = undefined;
window.isMobile = () => {
return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent)
|| /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substr(0, 4));
}
export function isDesktop() {
return !isMobile();
}
export function msgbox(type, title, text) {
if (!type || (type == 'error' && !text)) return;
const text2 = text.toLowerCase();
var hasRetry = checkIfRetry(type, title, text) ? 'true' : '';
onGlobalEvent(JSON.stringify({ name: 'msgbox', type, title, text, hasRetry }));
}
function jsonfyForDart(payload) {
var tmp = {};
for (const [key, value] of Object.entries(payload)) {
if (!key) continue;
tmp[key] = value instanceof Uint8Array ? '[' + value.toString() + ']' : JSON.stringify(value);
}
return tmp;
}
export function pushEvent(name, payload) {
payload = jsonfyForDart(payload);
payload.name = name;
onGlobalEvent(JSON.stringify(payload));
}
let yuvWorker;
let yuvCanvas;
let gl;
let pixels;
let flipPixels;
let oldSize;
if (YUVCanvas.WebGLFrameSink.isAvailable()) {
var canvas = document.createElement('canvas');
yuvCanvas = YUVCanvas.attach(canvas, { webGL: true });
gl = canvas.getContext("webgl");
} else {
yuvWorker = new Worker("./yuv.js");
}
let testSpeed = [0, 0];
export function draw(frame) {
if (yuvWorker) {
// frame's (y/u/v).bytes already detached, can not transferrable any more.
yuvWorker.postMessage(frame);
} else {
var tm0 = new Date().getTime();
yuvCanvas.drawFrame(frame);
var width = canvas.width;
var height = canvas.height;
var size = width * height * 4;
if (size != oldSize) {
pixels = new Uint8Array(size);
flipPixels = new Uint8Array(size);
oldSize = size;
}
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const row = width * 4;
const end = (height - 1) * row;
for (let i = 0; i < size; i += row) {
flipPixels.set(pixels.subarray(i, i + row), end - i);
}
onRgba(flipPixels);
testSpeed[1] += new Date().getTime() - tm0;
testSpeed[0] += 1;
if (testSpeed[0] > 30) {
console.log('gl: ' + parseInt('' + testSpeed[1] / testSpeed[0]));
testSpeed = [0, 0];
}
}
/*
var testCanvas = document.getElementById("test-yuv-decoder-canvas");
if (testCanvas && currentFrame) {
var ctx = testCanvas.getContext("2d");
testCanvas.width = frame.format.displayWidth;
testCanvas.height = frame.format.displayHeight;
var img = ctx.createImageData(testCanvas.width, testCanvas.height);
img.data.set(currentFrame);
ctx.putImageData(img, 0, 0);
}
*/
}
export function sendOffCanvas(c) {
let canvas = c.transferControlToOffscreen();
yuvWorker.postMessage({ canvas }, [canvas]);
}
export function setConn(conn) {
window.curConn = conn;
}
export function getConn() {
return window.curConn;
}
export async function startConn(id) {
setByName('remote_id', id);
await curConn.start(id);
}
export function close() {
getConn()?.close();
setConn(undefined);
}
export function newConn() {
window.curConn?.close();
const conn = new Connection();
setConn(conn);
return conn;
}
let sodium;
export async function verify(signed, pk) {
if (!sodium) {
await _sodium.ready;
sodium = _sodium;
}
if (typeof pk == 'string') {
pk = decodeBase64(pk);
}
return sodium.crypto_sign_open(signed, pk);
}
export function decodeBase64(pk) {
return sodium.from_base64(pk, sodium.base64_variants.ORIGINAL);
}
export function genBoxKeyPair() {
const pair = sodium.crypto_box_keypair();
const sk = pair.privateKey;
const pk = pair.publicKey;
return [sk, pk];
}
export function genSecretKey() {
return sodium.crypto_secretbox_keygen();
}
export function seal(unsigned, theirPk, ourSk) {
const nonce = Uint8Array.from(Array(24).fill(0));
return sodium.crypto_box_easy(unsigned, nonce, theirPk, ourSk);
}
function makeOnce(value) {
var byteArray = Array(24).fill(0);
for (var index = 0; index < byteArray.length && value > 0; index++) {
var byte = value & 0xff;
byteArray[index] = byte;
value = (value - byte) / 256;
}
return Uint8Array.from(byteArray);
};
export function encrypt(unsigned, nonce, key) {
return sodium.crypto_secretbox_easy(unsigned, makeOnce(nonce), key);
}
export function decrypt(signed, nonce, key) {
return sodium.crypto_secretbox_open_easy(signed, makeOnce(nonce), key);
}
window.setByName = (name, value) => {
switch (name) {
case 'remote_id':
localStorage.setItem('remote-id', value);
break;
case 'connect':
newConn();
startConn(value);
break;
case 'login':
value = JSON.parse(value);
curConn.setRemember(value.remember == 'true');
curConn.login(value.password);
break;
case 'close':
close();
break;
case 'refresh':
curConn.refresh();
break;
case 'reconnect':
curConn.reconnect();
break;
case 'toggle_option':
curConn.toggleOption(value);
break;
case 'image_quality':
curConn.setImageQuality(value);
break;
case 'lock_screen':
curConn.lockScreen();
break;
case 'ctrl_alt_del':
curConn.ctrlAltDel();
break;
case 'switch_display':
curConn.switchDisplay(value);
break;
case 'remove':
const peers = getPeers();
delete peers[value];
localStorage.setItem('peers', JSON.stringify(peers));
break;
case 'input_key':
value = JSON.parse(value);
curConn.inputKey(value.name, value.down == 'true', value.press == 'true', value.alt == 'true', value.ctrl == 'true', value.shift == 'true', value.command == 'true');
break;
case 'input_string':
curConn.inputString(value);
break;
case 'send_mouse':
let mask = 0;
value = JSON.parse(value);
switch (value.type) {
case 'down':
mask = 1;
break;
case 'up':
mask = 2;
break;
case 'wheel':
mask = 3;
break;
}
switch (value.buttons) {
case 'left':
mask |= 1 << 3;
break;
case 'right':
mask |= 2 << 3;
break;
case 'wheel':
mask |= 4 << 3;
}
curConn.inputMouse(mask, parseInt(value.x || '0'), parseInt(value.y || '0'), value.alt == 'true', value.ctrl == 'true', value.shift == 'true', value.command == 'true');
break;
case 'option':
value = JSON.parse(value);
localStorage.setItem(value.name, value.value);
break;
case 'peer_option':
value = JSON.parse(value);
curConn.setOption(value.name, value.value);
break;
case 'input_os_password':
curConn.inputOsPassword(value);
break;
default:
break;
}
}
window.getByName = (name, arg) => {
let v = _getByName(name, arg);
if (typeof v == 'string' || v instanceof String) return v;
if (v == undefined || v == null) return '';
return JSON.stringify(v);
}
function getPeersForDart() {
const peers = [];
for (const [id, value] of Object.entries(getPeers())) {
if (!id) continue;
const tm = value['tm'];
const info = value['info'];
if (!tm || !info) continue;
peers.push([tm, id, info]);
}
return peers.sort().reverse().map(x => x.slice(1));
}
function _getByName(name, arg) {
switch (name) {
case 'peers':
return getPeersForDart();
case 'remote_id':
return localStorage.getItem('remote-id');
case 'remember':
return curConn.getRemember();
case 'toggle_option':
return curConn.getOption(arg) || false;
case 'option':
return localStorage.getItem(arg);
case 'image_quality':
return curConn.getImageQuality();
case 'translate':
arg = JSON.parse(arg);
return translate(arg.locale, arg.text);
case 'peer_option':
return curConn.getOption(arg);
case 'test_if_valid_server':
break;
case 'version':
return version;
}
return '';
}
let opusWorker = new Worker("./libopus.js");
let pcmPlayer;
export function initAudio(channels, sampleRate) {
pcmPlayer = newAudioPlayer(channels, sampleRate);
opusWorker.postMessage({ channels, sampleRate });
}
export function playAudio(packet) {
opusWorker.postMessage(packet, [packet.buffer]);
}
window.init = async () => {
if (yuvWorker) {
yuvWorker.onmessage = (e) => {
onRgba(e.data);
}
}
opusWorker.onmessage = (e) => {
pcmPlayer.feed(e.data);
}
loadVp9(() => { });
await initZstd();
console.log('init done');
}
export function getPeers() {
try {
return JSON.parse(localStorage.getItem('peers')) || {};
} catch (e) {
return {};
}
}
function newAudioPlayer(channels, sampleRate) {
return new PCMPlayer({
channels,
sampleRate,
flushingTime: 2000
});
}
export function copyToClipboard(text) {
if (window.clipboardData && window.clipboardData.setData) {
// Internet Explorer-specific code path to prevent textarea being shown while dialog is visible.
return window.clipboardData.setData("Text", text);
}
else if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
var textarea = document.createElement("textarea");
textarea.textContent = text;
textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in Microsoft Edge.
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand("copy"); // Security exception may be thrown by some browsers.
}
catch (ex) {
console.warn("Copy to clipboard failed.", ex);
// return prompt("Copy to clipboard: Ctrl+C, Enter", text);
}
finally {
document.body.removeChild(textarea);
}
}
}

View File

@@ -1,2 +0,0 @@
import "./globals";
import "./ui";

View File

@@ -1,8 +0,0 @@
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}

View File

@@ -1,108 +0,0 @@
import "./style.css";
import "./connection";
import * as globals from "./globals";
const app = document.querySelector('#app');
if (app) {
app.innerHTML = `
<div id="connect" style="text-align: center"><table style="display: inline-block">
<tr><td><span>Host: </span></td><td><input id="host" /></td></tr>
<tr><td><span>Key: </span></td><td><input id="key" /></td></tr>
<tr><td><span>Id: </span></td><td><input id="id" /></td></tr>
<tr><td></td><td><button onclick="connect();">Connect</button></td></tr>
</table></div>
<div id="password" style="display: none;">
<input type="password" id="password" />
<button id="confirm" onclick="confirm()">Confirm</button>
<button id="cancel" onclick="cancel();">Cancel</button>
</div>
<div id="status" style="display: none;">
<div id="text" style="line-height: 2em"></div>
<button id="cancel" onclick="cancel();">Cancel</button>
</div>
<div id="canvas" style="display: none;">
<button id="cancel" onclick="cancel();">Cancel</button>
<canvas id="player"></canvas>
<canvas id="test-yuv-decoder-canvas"></canvas>
</div>
`;
let player;
window.init();
document.body.onload = () => {
const host = document.querySelector('#host');
host.value = localStorage.getItem('custom-rendezvous-server');
const id = document.querySelector('#id');
id.value = localStorage.getItem('id');
const key = document.querySelector('#key');
key.value = localStorage.getItem('key');
player = YUVCanvas.attach(document.getElementById('player'));
// globals.sendOffCanvas(document.getElementById('player'));
};
window.connect = () => {
const host = document.querySelector('#host');
localStorage.setItem('custom-rendezvous-server', host.value);
const id = document.querySelector('#id');
localStorage.setItem('id', id.value);
const key = document.querySelector('#key');
localStorage.setItem('key', key.value);
const func = async () => {
const conn = globals.newConn();
conn.setMsgbox(msgbox);
conn.setDraw((f) => {
/*
if (!(document.getElementById('player').width > 0)) {
document.getElementById('player').width = f.format.displayWidth;
document.getElementById('player').height = f.format.displayHeight;
}
*/
globals.draw(f);
player.drawFrame(f);
});
document.querySelector('div#status').style.display = 'block';
document.querySelector('div#connect').style.display = 'none';
document.querySelector('div#text').innerHTML = 'Connecting ...';
await conn.start(id.value);
};
func();
}
function msgbox(type, title, text) {
if (!globals.getConn()) return;
if (type == 'input-password') {
document.querySelector('div#status').style.display = 'none';
document.querySelector('div#password').style.display = 'block';
} else if (!type) {
document.querySelector('div#canvas').style.display = 'block';
document.querySelector('div#password').style.display = 'none';
document.querySelector('div#status').style.display = 'none';
} else if (type == 'error') {
document.querySelector('div#status').style.display = 'block';
document.querySelector('div#canvas').style.display = 'none';
document.querySelector('div#text').innerHTML = '<div style="color: red; font-weight: bold;">' + text + '</div>';
} else {
document.querySelector('div#password').style.display = 'none';
document.querySelector('div#status').style.display = 'block';
document.querySelector('div#text').innerHTML = '<div style="font-weight: bold;">' + text + '</div>';
}
}
window.cancel = () => {
globals.close();
document.querySelector('div#connect').style.display = 'block';
document.querySelector('div#password').style.display = 'none';
document.querySelector('div#status').style.display = 'none';
document.querySelector('div#canvas').style.display = 'none';
}
window.confirm = () => {
const password = document.querySelector('input#password').value;
if (password) {
document.querySelector('div#password').style.display = 'none';
globals.getConn().login(password);
}
}
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,183 +0,0 @@
import * as message from "./message.js";
import * as rendezvous from "./rendezvous.js";
import * as globals from "./globals";
type Keys = "message" | "open" | "close" | "error";
export default class Websock {
_websocket: WebSocket;
_eventHandlers: { [key in Keys]: Function };
_buf: (rendezvous.RendezvousMessage | message.Message)[];
_status: any;
_latency: number;
_secretKey: [Uint8Array, number, number] | undefined;
_uri: string;
_isRendezvous: boolean;
constructor(uri: string, isRendezvous: boolean = true) {
this._eventHandlers = {
message: (_: any) => {},
open: () => {},
close: () => {},
error: () => {},
};
this._uri = uri;
this._status = "";
this._buf = [];
this._websocket = new WebSocket(uri);
this._websocket.onmessage = this._recv_message.bind(this);
this._websocket.binaryType = "arraybuffer";
this._latency = new Date().getTime();
this._isRendezvous = isRendezvous;
}
latency(): number {
return this._latency;
}
setSecretKey(key: Uint8Array) {
this._secretKey = [key, 0, 0];
}
sendMessage(json: message.DeepPartial<message.Message>) {
let data = message.Message.encode(
message.Message.fromPartial(json)
).finish();
let k = this._secretKey;
if (k) {
k[1] += 1;
data = globals.encrypt(data, k[1], k[0]);
}
this._websocket.send(data);
}
sendRendezvous(data: rendezvous.DeepPartial<rendezvous.RendezvousMessage>) {
this._websocket.send(
rendezvous.RendezvousMessage.encode(
rendezvous.RendezvousMessage.fromPartial(data)
).finish()
);
}
parseMessage(data: Uint8Array) {
return message.Message.decode(data);
}
parseRendezvous(data: Uint8Array) {
return rendezvous.RendezvousMessage.decode(data);
}
// Event Handlers
off(evt: Keys) {
this._eventHandlers[evt] = () => {};
}
on(evt: Keys, handler: Function) {
this._eventHandlers[evt] = handler;
}
async open(timeout: number = 12000): Promise<Websock> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (this._status != "open") {
reject(this._status || "Timeout");
}
}, timeout);
this._websocket.onopen = () => {
this._latency = new Date().getTime() - this._latency;
this._status = "open";
console.debug(">> WebSock.onopen");
if (this._websocket?.protocol) {
console.info(
"Server choose sub-protocol: " + this._websocket.protocol
);
}
this._eventHandlers.open();
console.info("WebSock.onopen");
resolve(this);
};
this._websocket.onclose = (e) => {
if (this._status == "open") {
// e.code 1000 means that the connection was closed normally.
//
}
this._status = e;
console.error("WebSock.onclose: ");
console.error(e);
this._eventHandlers.close(e);
reject("Reset by the peer");
};
this._websocket.onerror = (e: any) => {
if (!this._status) {
reject("Failed to connect to " + (this._isRendezvous ? "rendezvous" : "relay") + " server");
return;
}
this._status = e;
console.error("WebSock.onerror: ")
console.error(e);
this._eventHandlers.error(e);
};
});
}
async next(
timeout = 12000
): Promise<rendezvous.RendezvousMessage | message.Message> {
const func = (
resolve: (value: rendezvous.RendezvousMessage | message.Message) => void,
reject: (reason: any) => void,
tm0: number
) => {
if (this._buf.length) {
resolve(this._buf[0]);
this._buf.splice(0, 1);
} else {
if (this._status != "open") {
reject(this._status);
return;
}
if (new Date().getTime() > tm0 + timeout) {
reject("Timeout");
} else {
setTimeout(() => func(resolve, reject, tm0), 1);
}
}
};
return new Promise((resolve, reject) => {
func(resolve, reject, new Date().getTime());
});
}
close() {
this._status = "";
if (this._websocket) {
if (
this._websocket.readyState === WebSocket.OPEN ||
this._websocket.readyState === WebSocket.CONNECTING
) {
console.info("Closing WebSocket connection");
this._websocket.close();
}
this._websocket.onmessage = () => {};
}
}
_recv_message(e: any) {
if (e.data instanceof window.ArrayBuffer) {
let bytes = new Uint8Array(e.data);
const k = this._secretKey;
if (k) {
k[2] += 1;
bytes = globals.decrypt(bytes, k[2], k[0]);
}
this._buf.push(
this._isRendezvous
? this.parseRendezvous(bytes)
: this.parseMessage(bytes)
);
}
this._eventHandlers.message(e.data);
}
}

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env python
import os
path = os.path.abspath(os.path.join(os.getcwd(), '..', '..', '..', 'libs', 'hbb_common', 'protos'))
if os.name == 'nt':
cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=protoc-gen-ts_proto=.\node_modules\.bin\protoc-gen-ts_proto.cmd -I "%s" --ts_proto_out=./src/ rendezvous.proto'%path
print(cmd)
os.system(cmd)
cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=protoc-gen-ts_proto=.\node_modules\.bin\protoc-gen-ts_proto.cmd -I "%s" --ts_proto_out=./src/ message.proto'%path
print(cmd)
os.system(cmd)
else:
cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=./node_modules/.bin/protoc-gen-ts_proto -I "%s" --ts_proto_out=./src/ rendezvous.proto'%path
print(cmd)
os.system(cmd)
cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=./node_modules/.bin/protoc-gen-ts_proto -I "%s" --ts_proto_out=./src/ message.proto'%path
print(cmd)
os.system(cmd)

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"allowJs": true,
"lib": [
"ESNext",
"DOM"
],
"moduleResolution": "Node",
"strict": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
},
"include": [
"./src"
]
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
manifest: false,
rollupOptions: {
output: {
entryFileNames: `[name].js`,
chunkFileNames: `[name].js`,
assetFileNames: `[name].[ext]`,
}
}
},
})

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,35 +0,0 @@
{
"name": "rustdesk",
"short_name": "rustdesk",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Remote Desktop.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -1,73 +0,0 @@
var wasmExports;
fetch('yuv.wasm').then(function (res) { return res.arrayBuffer(); })
.then(function (file) { return WebAssembly.instantiate(file); })
.then(function (wasm) {
wasmExports = wasm.instance.exports;
console.log('yuv ready');
});
var yPtr, yPtrLen, uPtr, uPtrLen, vPtr, vPtrLen, outPtr, outPtrLen;
let testSpeed = [0, 0];
function I420ToARGB(yb) {
if (!wasmExports) return;
var tm0 = new Date().getTime();
var { malloc, free, memory } = wasmExports;
var HEAPU8 = new Uint8Array(memory.buffer);
let n = yb.y.bytes.length;
if (yPtrLen != n) {
if (yPtr) free(yPtr);
yPtrLen = n;
yPtr = malloc(n);
}
HEAPU8.set(yb.y.bytes, yPtr);
n = yb.u.bytes.length;
if (uPtrLen != n) {
if (uPtr) free(uPtr);
uPtrLen = n;
uPtr = malloc(n);
}
HEAPU8.set(yb.u.bytes, uPtr);
n = yb.v.bytes.length;
if (vPtrLen != n) {
if (vPtr) free(vPtr);
vPtrLen = n;
vPtr = malloc(n);
}
HEAPU8.set(yb.v.bytes, vPtr);
var w = yb.format.displayWidth;
var h = yb.format.displayHeight;
n = w * h * 4;
if (outPtrLen != n) {
if (outPtr) free(outPtr);
outPtrLen = n;
outPtr = malloc(n);
HEAPU8.fill(255, outPtr, outPtr + n);
}
// var res = wasmExports.I420ToARGB(yPtr, yb.y.stride, uPtr, yb.u.stride, vPtr, yb.v.stride, outPtr, w * 4, w, h);
// var res = wasmExports.AVX_YUV_to_ARGB(outPtr, yPtr, yb.y.stride, uPtr, yb.u.stride, vPtr, yb.v.stride, w, h);
var res = wasmExports.yuv420_rgb24_std(w, h, yPtr, uPtr, vPtr, yb.y.stride, yb.v.stride, outPtr, w * 4, 1);
var out = HEAPU8.slice(outPtr, outPtr + n);
testSpeed[1] += new Date().getTime() - tm0;
testSpeed[0] += 1;
if (testSpeed[0] > 30) {
console.log('yuv: ' + parseInt('' + testSpeed[1] / testSpeed[0]));
testSpeed = [0, 0];
}
return out;
}
var currentFrame;
self.addEventListener('message', (e) => {
currentFrame = e.data;
});
function run() {
if (currentFrame) {
self.postMessage(I420ToARGB(currentFrame));
currentFrame = undefined;
}
setTimeout(run, 1);
}
run();

Binary file not shown.

View File

@@ -1 +0,0 @@
Under dev.