mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-03-22 12:41:14 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
98
flutter/lib/desktop/pages/terminal_connection_manager.dart
Normal file
98
flutter/lib/desktop/pages/terminal_connection_manager.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
121
flutter/lib/desktop/pages/terminal_page.dart
Normal file
121
flutter/lib/desktop/pages/terminal_page.dart
Normal 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;
|
||||
}
|
||||
384
flutter/lib/desktop/pages/terminal_tab_page.dart
Normal file
384
flutter/lib/desktop/pages/terminal_tab_page.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
27
flutter/lib/desktop/screen/desktop_terminal_screen.dart
Normal file
27
flutter/lib/desktop/screen/desktop_terminal_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ enum DesktopTabType {
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
terminal,
|
||||
install,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
106
flutter/lib/mobile/pages/terminal_page.dart
Normal file
106
flutter/lib/mobile/pages/terminal_page.dart
Normal 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;
|
||||
}
|
||||
@@ -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: []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
269
flutter/lib/models/terminal_model.dart
Normal file
269
flutter/lib/models/terminal_model.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
9
flutter/web/v1/.gitignore
vendored
9
flutter/web/v1/.gitignore
vendored
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
v1 is not compatible with current Flutter source code.
|
||||
@@ -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>
|
||||
1
flutter/web/v1/js/.gitattributes
vendored
1
flutter/web/v1/js/.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
* text=auto
|
||||
9
flutter/web/v1/js/.gitignore
vendored
9
flutter/web/v1/js/.gitignore
vendored
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*log
|
||||
ogvjs
|
||||
.vscode
|
||||
.yarn
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import "./globals";
|
||||
import "./ui";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
flutter/web/v1/js/src/vite-env.d.ts
vendored
1
flutter/web/v1/js/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -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.
@@ -1 +0,0 @@
|
||||
Under dev.
|
||||
Reference in New Issue
Block a user