import 'dart:async'; import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; class SshService { SSHClient? _client; SSHSession? _session; Timer? _keepAliveTimer; // Broadcast controller so multiple listeners (UI, loggers) can subscribe final StreamController _outputController = StreamController.broadcast(); // Controller to notify about disconnection (true = clean exit/0, false = error/drop) final StreamController _disconnectController = StreamController.broadcast(); Stream get outputStream => _outputController.stream; Stream get disconnectStream => _disconnectController.stream; bool get isConnected => _client != null && !(_client?.isClosed ?? true); Future connect({ required String host, required int port, required String username, String? password, String? privateKey, }) async { try { _outputController.add('Connecting to $host:$port as $username...\n'); final socket = await SSHSocket.connect(host, port); _client = SSHClient( socket, username: username, onPasswordRequest: password != null ? () => password : null, identities: privateKey != null ? SSHKeyPair.fromPem(privateKey) : [], ); _outputController.add('Authenticating...\n'); await _client!.authenticated; _outputController.add('Authenticated. Starting shell...\n'); _startKeepAlive(); await _startShell(); } catch (e) { _outputController.add('Connection failed: $e\n'); await disconnect(); rethrow; } } void _startKeepAlive() { _keepAliveTimer?.cancel(); _keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) async { if (!isConnected) { timer.cancel(); return; } try { // Send a window change request with the same size. // This generates valid SSH traffic to keep the connection alive. _session?.resizeTerminal(80, 24); } catch (_) { // Fails silently; the main connection handlers will deal with actual drops. } }); } Future _startShell() async { if (_client == null) return; try { _session = await _client!.shell( pty: const SSHPtyConfig( width: 80, height: 24, ), ); // Listen for session completion (e.g. exit command) _session!.done.then((_) { final code = _session!.exitCode; disconnect(isClean: code == 0); }); // Use transform(utf8.decoder) to correctly handle multi-byte characters split across chunks _session!.stdout.cast>().transform(const Utf8Decoder()).listen( (data) => _outputController.add(data), onError: (e) => _outputController.add('Error reading stdout: $e\n'), onDone: () { if (isConnected) _outputController.add('Session stdout closed.\n'); }, ); _session!.stderr.cast>().transform(const Utf8Decoder()).listen( (data) => _outputController.add(data), onError: (e) => _outputController.add('Error reading stderr: $e\n'), ); // Wait for the connection to complete (unexpected drop or client close) _client!.done.then((_) { _outputController.add('Connection closed.\n'); disconnect(isClean: false); }); } catch (e) { _outputController.add('Failed to start shell: $e\n'); await disconnect(); } } // --- SFTP Operations --- Future _getSftpClient() async { if (_client == null) throw Exception('Not connected'); return await _client!.sftp(); } Future> listDirectory(String path) async { final sftp = await _getSftpClient(); return await sftp.listdir(path); } Future readFile(String path) async { final sftp = await _getSftpClient(); // Check file size first final stat = await sftp.stat(path); if ((stat.size ?? 0) > 1024 * 1024) { // 1MB limit throw Exception('File is too large to edit (Max 1MB).'); } final file = await sftp.open(path); final content = await file.readBytes(); return utf8.decode(content); } Future writeFile(String path, String content) async { final sftp = await _getSftpClient(); final file = await sftp.open(path, mode: SftpFileOpenMode.create | SftpFileOpenMode.write | SftpFileOpenMode.truncate); await file.writeBytes(utf8.encode(content)); await file.close(); } void write(String data) { if (_session != null) { _session!.write(utf8.encode(data)); } } Future disconnect({bool isClean = false}) async { _keepAliveTimer?.cancel(); if (_client == null && _session == null) return; _session?.close(); _client?.close(); _client = null; _session = null; _disconnectController.add(isClean); } void dispose() { _keepAliveTimer?.cancel(); _outputController.close(); _disconnectController.close(); } }