Initial commit: Accessible SSH Terminal
This commit is contained in:
69
lib/state/saved_connections_provider.dart
Normal file
69
lib/state/saved_connections_provider.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:accessible_terminal/models/saved_connection.dart';
|
||||
|
||||
// Key for SharedPreferences
|
||||
const String _kSavedConnectionsKey = 'saved_connections';
|
||||
|
||||
class SavedConnectionsNotifier extends StateNotifier<List<SavedConnection>> {
|
||||
SavedConnectionsNotifier() : super([]) {
|
||||
_loadConnections();
|
||||
}
|
||||
|
||||
Future<void> _loadConnections() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? jsonString = prefs.getString(_kSavedConnectionsKey);
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonString);
|
||||
state = jsonList.map((e) => SavedConnection.fromJson(e)).toList();
|
||||
} catch (e) {
|
||||
// Handle corruption or format change gracefully
|
||||
state = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addConnection({
|
||||
required String host,
|
||||
required int port,
|
||||
required String username,
|
||||
String? privateKey,
|
||||
}) async {
|
||||
// Check for duplicates (simple check based on host/user/port)
|
||||
final exists = state.any((c) =>
|
||||
c.host == host && c.port == port && c.username == username
|
||||
);
|
||||
|
||||
if (exists) return; // Don't save if already exists
|
||||
|
||||
// Use timestamp for simple ID generation
|
||||
final simpleId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final connection = SavedConnection(
|
||||
id: simpleId,
|
||||
host: host,
|
||||
port: port,
|
||||
username: username,
|
||||
privateKey: privateKey,
|
||||
);
|
||||
|
||||
state = [...state, connection];
|
||||
await _saveToDisk();
|
||||
}
|
||||
|
||||
Future<void> removeConnection(String id) async {
|
||||
state = state.where((c) => c.id != id).toList();
|
||||
await _saveToDisk();
|
||||
}
|
||||
|
||||
Future<void> _saveToDisk() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = state.map((c) => c.toJson()).toList();
|
||||
await prefs.setString(_kSavedConnectionsKey, jsonEncode(jsonList));
|
||||
}
|
||||
}
|
||||
|
||||
final savedConnectionsProvider = StateNotifierProvider<SavedConnectionsNotifier, List<SavedConnection>>((ref) {
|
||||
return SavedConnectionsNotifier();
|
||||
});
|
||||
141
lib/state/terminal_provider.dart
Normal file
141
lib/state/terminal_provider.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:accessible_terminal/models/terminal_line.dart';
|
||||
import 'package:accessible_terminal/services/ssh_service.dart';
|
||||
|
||||
// Provider for the SSH Service instance
|
||||
final sshServiceProvider = Provider<SshService>((ref) {
|
||||
final service = SshService();
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
// State for connection status
|
||||
final connectionStateProvider = StateProvider<bool>((ref) => false);
|
||||
// State for connection errors
|
||||
final connectionErrorProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
class TerminalNotifier extends StateNotifier<List<TerminalLine>> {
|
||||
final SshService _sshService;
|
||||
final StateController<bool> _connectionStateController;
|
||||
final StateController<String?> _errorController;
|
||||
String _currentLineBuffer = '';
|
||||
bool _isUserInitiatedDisconnect = false;
|
||||
|
||||
TerminalNotifier(this._sshService, this._connectionStateController, this._errorController) : super([]) {
|
||||
_sshService.outputStream.listen((data) {
|
||||
_processOutput(data);
|
||||
});
|
||||
|
||||
_sshService.disconnectStream.listen((isClean) {
|
||||
if (!_isUserInitiatedDisconnect && !isClean && _connectionStateController.state) {
|
||||
_errorController.state = 'Connection lost unexpectedly (Timeout or Server closed).';
|
||||
}
|
||||
_connectionStateController.state = false;
|
||||
_processOutput('\n[Connection Closed]\n');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> connect(String host, int port, String username, {String? password, String? privateKey}) async {
|
||||
_isUserInitiatedDisconnect = false;
|
||||
try {
|
||||
state = [TerminalLine(text: 'Connecting to $host...'), ...state];
|
||||
await _sshService.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
privateKey: privateKey,
|
||||
);
|
||||
_connectionStateController.state = true;
|
||||
} catch (e) {
|
||||
state = [TerminalLine(text: 'Error: $e'), ...state];
|
||||
_connectionStateController.state = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isUserInitiatedDisconnect = true;
|
||||
await _sshService.disconnect();
|
||||
_connectionStateController.state = false;
|
||||
}
|
||||
|
||||
void sendInput(String input) {
|
||||
if (_connectionStateController.state) {
|
||||
_sshService.write(input);
|
||||
}
|
||||
}
|
||||
|
||||
void _processOutput(String data) {
|
||||
// Normalize line endings to \n
|
||||
final normalizedData = data.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
|
||||
final parts = normalizedData.split('\n');
|
||||
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
|
||||
if (i == 0) {
|
||||
// Append to the current pending line
|
||||
_currentLineBuffer += part;
|
||||
} else {
|
||||
// The previous part ended with a newline, so push the buffer and start new
|
||||
if (_currentLineBuffer.isNotEmpty) {
|
||||
state = [TerminalLine(text: _currentLineBuffer), ...state];
|
||||
} else if (state.isNotEmpty) {
|
||||
// Handle empty lines if needed, or just spacers
|
||||
state = [TerminalLine(text: ''), ...state];
|
||||
}
|
||||
_currentLineBuffer = part;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't push the last chunk immediately if it doesn't end with \n
|
||||
// It remains in _currentLineBuffer for the next packet.
|
||||
// However, to see typing in real-time, we might want to update the latest line on screen?
|
||||
// For a log-based view (reverse list), the "active" line is usually at index 0.
|
||||
// If we have a buffer, we might want to temporarily show it.
|
||||
// For simplicity in this step, we only commit lines when a newline occurs,
|
||||
// OR we can perform a more complex "update top line" logic.
|
||||
// Let's stick to: if we have a buffer, it's NOT in the list yet.
|
||||
// BUT this makes the prompt invisible until you hit enter.
|
||||
// FIX: Always expose the buffer as the first element if it exists?
|
||||
|
||||
// Better approach for "live" feel in a chat/log interface:
|
||||
// If we have content in _currentLineBuffer, we should probably show it.
|
||||
// Since we can't easily "edit" the immutable list state efficiently without copying,
|
||||
// we might just accept that partial lines aren't committed or we commit every chunk.
|
||||
// Let's commit every chunk for now to ensure feedback, even if it splits weirdly?
|
||||
// No, that breaks the "line" concept.
|
||||
|
||||
// Compromise: We won't show the partial line in the *history* list,
|
||||
// but the UI could consume `_currentLineBuffer` if we exposed it.
|
||||
// OR, we just push it to state and replace the first element if it was partial?
|
||||
// Too complex for now. Let's just push lines as they complete.
|
||||
// Actually, for SSH, echoing characters back is handled by the remote.
|
||||
// If I type 'a', remote sends 'a'. I want to see 'a'.
|
||||
// If I don't flush _currentLineBuffer, I won't see it.
|
||||
// So I MUST flush _currentLineBuffer to the state if it's not empty,
|
||||
// effectively "updating" the top line.
|
||||
|
||||
if (_currentLineBuffer.isNotEmpty) {
|
||||
// Filter out purely empty or whitespace-only lines to reduce accessibility noise
|
||||
if (_currentLineBuffer.trim().isNotEmpty) {
|
||||
state = [TerminalLine(text: _currentLineBuffer), ...state];
|
||||
}
|
||||
_currentLineBuffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = [];
|
||||
_currentLineBuffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
final terminalOutputProvider = StateNotifierProvider<TerminalNotifier, List<TerminalLine>>((ref) {
|
||||
final sshService = ref.watch(sshServiceProvider);
|
||||
final connectionState = ref.watch(connectionStateProvider.notifier);
|
||||
final errorController = ref.watch(connectionErrorProvider.notifier);
|
||||
return TerminalNotifier(sshService, connectionState, errorController);
|
||||
});
|
||||
Reference in New Issue
Block a user