Files
accessible-ssh/lib/services/ssh_service.dart

170 lines
4.9 KiB
Dart

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<String> _outputController = StreamController<String>.broadcast();
// Controller to notify about disconnection (true = clean exit/0, false = error/drop)
final StreamController<bool> _disconnectController = StreamController.broadcast();
Stream<String> get outputStream => _outputController.stream;
Stream<bool> get disconnectStream => _disconnectController.stream;
bool get isConnected => _client != null && !(_client?.isClosed ?? true);
Future<void> 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<void> _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<List<int>>().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<List<int>>().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<SftpClient> _getSftpClient() async {
if (_client == null) throw Exception('Not connected');
return await _client!.sftp();
}
Future<List<SftpName>> listDirectory(String path) async {
final sftp = await _getSftpClient();
return await sftp.listdir(path);
}
Future<String> 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<void> 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<void> 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();
}
}