terminal_model.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'package:desktop_multi_window/desktop_multi_window.dart';
  4. import 'package:flutter/foundation.dart';
  5. import 'package:flutter/material.dart';
  6. import 'package:flutter_hbb/common.dart';
  7. import 'package:flutter_hbb/consts.dart';
  8. import 'package:flutter_hbb/main.dart';
  9. import 'package:xterm/xterm.dart';
  10. import 'model.dart';
  11. import 'platform_model.dart';
  12. class TerminalModel with ChangeNotifier {
  13. final String id; // peer id
  14. final FFI parent;
  15. final int terminalId;
  16. late final Terminal terminal;
  17. late final TerminalController terminalController;
  18. bool _terminalOpened = false;
  19. bool get terminalOpened => _terminalOpened;
  20. bool _disposed = false;
  21. final _inputBuffer = <String>[];
  22. bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
  23. Future<void> _handleInput(String data) async {
  24. // If we press the `Enter` button on Android,
  25. // `data` can be '\r' or '\n' when using different keyboards.
  26. // Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
  27. // Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
  28. // So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
  29. // Desktop -> Desktop works fine.
  30. // Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
  31. final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
  32. if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
  33. data = '\r';
  34. }
  35. if (_terminalOpened) {
  36. // Send user input to remote terminal
  37. try {
  38. await bind.sessionSendTerminalInput(
  39. sessionId: parent.sessionId,
  40. terminalId: terminalId,
  41. data: data,
  42. );
  43. } catch (e) {
  44. debugPrint('[TerminalModel] Error sending terminal input: $e');
  45. }
  46. } else {
  47. debugPrint('[TerminalModel] Terminal not opened yet, buffering input');
  48. _inputBuffer.add(data);
  49. }
  50. }
  51. TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
  52. terminal = Terminal(maxLines: 10000);
  53. terminalController = TerminalController();
  54. // Setup terminal callbacks
  55. terminal.onOutput = _handleInput;
  56. terminal.onResize = (w, h, pw, ph) async {
  57. // Validate all dimensions before using them
  58. if (w > 0 && h > 0 && pw > 0 && ph > 0) {
  59. debugPrint(
  60. '[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
  61. if (_terminalOpened) {
  62. // Notify remote terminal of resize
  63. try {
  64. await bind.sessionResizeTerminal(
  65. sessionId: parent.sessionId,
  66. terminalId: terminalId,
  67. rows: h,
  68. cols: w,
  69. );
  70. } catch (e) {
  71. debugPrint('[TerminalModel] Error resizing terminal: $e');
  72. }
  73. }
  74. } else {
  75. debugPrint(
  76. '[TerminalModel] Invalid terminal dimensions: ${w}x$h (pixel: ${pw}x$ph)');
  77. }
  78. };
  79. }
  80. void onReady() {
  81. parent.dialogManager.dismissAll();
  82. // Fire and forget - don't block onReady
  83. openTerminal().catchError((e) {
  84. debugPrint('[TerminalModel] Error opening terminal: $e');
  85. });
  86. }
  87. Future<void> openTerminal() async {
  88. if (_terminalOpened) return;
  89. // Request the remote side to open a terminal with default shell
  90. // The remote side will decide which shell to use based on its OS
  91. // Get terminal dimensions, ensuring they are valid
  92. int rows = 24;
  93. int cols = 80;
  94. if (terminal.viewHeight > 0) {
  95. rows = terminal.viewHeight;
  96. }
  97. if (terminal.viewWidth > 0) {
  98. cols = terminal.viewWidth;
  99. }
  100. debugPrint(
  101. '[TerminalModel] Opening terminal $terminalId, sessionId: ${parent.sessionId}, size: ${cols}x$rows');
  102. try {
  103. await bind
  104. .sessionOpenTerminal(
  105. sessionId: parent.sessionId,
  106. terminalId: terminalId,
  107. rows: rows,
  108. cols: cols,
  109. )
  110. .timeout(
  111. const Duration(seconds: 5),
  112. onTimeout: () {
  113. throw TimeoutException(
  114. 'sessionOpenTerminal timed out after 5 seconds');
  115. },
  116. );
  117. debugPrint('[TerminalModel] sessionOpenTerminal called successfully');
  118. } catch (e) {
  119. debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
  120. // Optionally show error to user
  121. if (e is TimeoutException) {
  122. terminal.write('Failed to open terminal: Connection timeout\r\n');
  123. }
  124. }
  125. }
  126. Future<void> closeTerminal() async {
  127. if (_terminalOpened) {
  128. try {
  129. await bind
  130. .sessionCloseTerminal(
  131. sessionId: parent.sessionId,
  132. terminalId: terminalId,
  133. )
  134. .timeout(
  135. const Duration(seconds: 3),
  136. onTimeout: () {
  137. throw TimeoutException(
  138. 'sessionCloseTerminal timed out after 3 seconds');
  139. },
  140. );
  141. debugPrint('[TerminalModel] sessionCloseTerminal called successfully');
  142. } catch (e) {
  143. debugPrint('[TerminalModel] Error calling sessionCloseTerminal: $e');
  144. // Continue with cleanup even if close fails
  145. }
  146. _terminalOpened = false;
  147. notifyListeners();
  148. }
  149. }
  150. static int getTerminalIdFromEvt(Map<String, dynamic> evt) {
  151. if (evt.containsKey('terminal_id')) {
  152. final v = evt['terminal_id'];
  153. if (v is int) {
  154. // Desktop and mobile send terminal_id as an int
  155. return v;
  156. } else if (v is String) {
  157. // Web sends terminal_id as a string
  158. final parsed = int.tryParse(v);
  159. if (parsed != null) {
  160. return parsed;
  161. } else {
  162. debugPrint(
  163. '[TerminalModel] Failed to parse terminal_id as integer: $v. Expected a numeric string.');
  164. return 0;
  165. }
  166. } else {
  167. // Unexpected type, log and handle gracefully
  168. debugPrint(
  169. '[TerminalModel] Unexpected terminal_id type: ${v.runtimeType}, value: $v. Expected int or String.');
  170. return 0;
  171. }
  172. } else {
  173. debugPrint('[TerminalModel] Event does not contain terminal_id');
  174. return 0;
  175. }
  176. }
  177. static bool getSuccessFromEvt(Map<String, dynamic> evt) {
  178. if (evt.containsKey('success')) {
  179. final v = evt['success'];
  180. if (v is bool) {
  181. // Desktop and mobile
  182. return v;
  183. } else if (v is String) {
  184. // Web
  185. return v.toLowerCase() == 'true';
  186. } else {
  187. // Unexpected type, log and handle gracefully
  188. debugPrint(
  189. '[TerminalModel] Unexpected success type: ${v.runtimeType}, value: $v. Expected bool or String.');
  190. return false;
  191. }
  192. } else {
  193. debugPrint('[TerminalModel] Event does not contain success');
  194. return false;
  195. }
  196. }
  197. void handleTerminalResponse(Map<String, dynamic> evt) {
  198. final String? type = evt['type'];
  199. final int evtTerminalId = getTerminalIdFromEvt(evt);
  200. // Only handle events for this terminal
  201. if (evtTerminalId != terminalId) {
  202. debugPrint(
  203. '[TerminalModel] Ignoring event for terminal $evtTerminalId (not mine)');
  204. return;
  205. }
  206. switch (type) {
  207. case 'opened':
  208. _handleTerminalOpened(evt);
  209. break;
  210. case 'data':
  211. _handleTerminalData(evt);
  212. break;
  213. case 'closed':
  214. _handleTerminalClosed(evt);
  215. break;
  216. case 'error':
  217. _handleTerminalError(evt);
  218. break;
  219. }
  220. }
  221. void _handleTerminalOpened(Map<String, dynamic> evt) {
  222. final bool success = getSuccessFromEvt(evt);
  223. final String message = evt['message'] ?? '';
  224. final String? serviceId = evt['service_id'];
  225. debugPrint(
  226. '[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
  227. if (success) {
  228. _terminalOpened = true;
  229. // Service ID is now saved on the Rust side in handle_terminal_response
  230. // Process any buffered input
  231. _processBufferedInputAsync().then((_) {
  232. notifyListeners();
  233. }).catchError((e) {
  234. debugPrint('[TerminalModel] Error processing buffered input: $e');
  235. notifyListeners();
  236. });
  237. final persistentSessions =
  238. evt['persistent_sessions'] as List<dynamic>? ?? [];
  239. if (kWindowId != null && persistentSessions.isNotEmpty) {
  240. DesktopMultiWindow.invokeMethod(
  241. kWindowId!,
  242. kWindowEventRestoreTerminalSessions,
  243. jsonEncode({
  244. 'persistent_sessions': persistentSessions,
  245. }));
  246. }
  247. } else {
  248. terminal.write('Failed to open terminal: $message\r\n');
  249. }
  250. }
  251. Future<void> _processBufferedInputAsync() async {
  252. final buffer = List<String>.from(_inputBuffer);
  253. _inputBuffer.clear();
  254. for (final data in buffer) {
  255. try {
  256. await bind.sessionSendTerminalInput(
  257. sessionId: parent.sessionId,
  258. terminalId: terminalId,
  259. data: data,
  260. );
  261. } catch (e) {
  262. debugPrint('[TerminalModel] Error sending buffered input: $e');
  263. }
  264. }
  265. }
  266. void _handleTerminalData(Map<String, dynamic> evt) {
  267. final data = evt['data'];
  268. if (data != null) {
  269. try {
  270. String text = '';
  271. if (data is String) {
  272. // Try to decode as base64 first
  273. try {
  274. final bytes = base64Decode(data);
  275. text = utf8.decode(bytes);
  276. } catch (e) {
  277. // If base64 decode fails, treat as plain text
  278. text = data;
  279. }
  280. } else if (data is List) {
  281. // Handle if data comes as byte array
  282. text = utf8.decode(List<int>.from(data));
  283. } else {
  284. debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}');
  285. return;
  286. }
  287. terminal.write(text);
  288. } catch (e) {
  289. debugPrint('[TerminalModel] Failed to process terminal data: $e');
  290. }
  291. }
  292. }
  293. void _handleTerminalClosed(Map<String, dynamic> evt) {
  294. final int exitCode = evt['exit_code'] ?? 0;
  295. terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n');
  296. _terminalOpened = false;
  297. notifyListeners();
  298. }
  299. void _handleTerminalError(Map<String, dynamic> evt) {
  300. final String message = evt['message'] ?? 'Unknown error';
  301. terminal.write('\r\nTerminal error: $message\r\n');
  302. }
  303. @override
  304. void dispose() {
  305. if (_disposed) return;
  306. _disposed = true;
  307. // Terminal cleanup is handled server-side when service closes
  308. super.dispose();
  309. }
  310. }