mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
395 lines
13 KiB
Dart
395 lines
13 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:computer/computer.dart';
|
||
import 'package:dartssh2/dartssh2.dart';
|
||
import 'package:fl_lib/fl_lib.dart';
|
||
import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart';
|
||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||
import 'package:server_box/core/extension/ssh_client.dart';
|
||
import 'package:server_box/core/utils/server.dart';
|
||
import 'package:server_box/core/utils/ssh_auth.dart';
|
||
import 'package:server_box/data/helper/system_detector.dart';
|
||
import 'package:server_box/data/model/app/error.dart';
|
||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||
import 'package:server_box/data/model/app/scripts/shell_func.dart';
|
||
import 'package:server_box/data/model/server/connection_stat.dart';
|
||
import 'package:server_box/data/model/server/server.dart';
|
||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||
import 'package:server_box/data/model/server/server_status_update_req.dart';
|
||
import 'package:server_box/data/model/server/system.dart';
|
||
import 'package:server_box/data/model/server/try_limiter.dart';
|
||
import 'package:server_box/data/provider/server/all.dart';
|
||
import 'package:server_box/data/res/status.dart';
|
||
import 'package:server_box/data/res/store.dart';
|
||
import 'package:server_box/data/ssh/session_manager.dart';
|
||
|
||
part 'single.g.dart';
|
||
part 'single.freezed.dart';
|
||
|
||
// Individual server state, including connection and status information
|
||
@freezed
|
||
abstract class ServerState with _$ServerState {
|
||
const factory ServerState({
|
||
required Spi spi,
|
||
required ServerStatus status,
|
||
@Default(ServerConn.disconnected) ServerConn conn,
|
||
SSHClient? client,
|
||
Future<void>? updateFuture,
|
||
}) = _ServerState;
|
||
}
|
||
|
||
// Individual server state management
|
||
@Riverpod(keepAlive: true)
|
||
class ServerNotifier extends _$ServerNotifier {
|
||
@override
|
||
ServerState build(String serverId) {
|
||
final serverNotifier = ref.read(serversNotifierProvider);
|
||
final spi = serverNotifier.servers[serverId];
|
||
if (spi == null) {
|
||
throw StateError('Server $serverId not found');
|
||
}
|
||
|
||
return ServerState(spi: spi, status: InitStatus.status);
|
||
}
|
||
|
||
// Update connection status
|
||
void updateConnection(ServerConn conn) {
|
||
state = state.copyWith(conn: conn);
|
||
}
|
||
|
||
// Update server status
|
||
void updateStatus(ServerStatus status) {
|
||
state = state.copyWith(status: status);
|
||
}
|
||
|
||
// Update SSH client
|
||
void updateClient(SSHClient? client) {
|
||
state = state.copyWith(client: client);
|
||
}
|
||
|
||
// Update SPI configuration
|
||
void updateSpi(Spi spi) {
|
||
state = state.copyWith(spi: spi);
|
||
}
|
||
|
||
// Close connection
|
||
void closeConnection() {
|
||
final client = state.client;
|
||
client?.close();
|
||
state = state.copyWith(client: null, conn: ServerConn.disconnected);
|
||
}
|
||
|
||
// Refresh server status
|
||
Future<void> refresh() async {
|
||
if (state.updateFuture != null) {
|
||
await state.updateFuture;
|
||
return;
|
||
}
|
||
|
||
final updateFuture = _updateServer();
|
||
state = state.copyWith(updateFuture: updateFuture);
|
||
|
||
try {
|
||
await updateFuture;
|
||
} finally {
|
||
state = state.copyWith(updateFuture: null);
|
||
}
|
||
}
|
||
|
||
Future<void> _updateServer() async {
|
||
await _getData();
|
||
}
|
||
|
||
Future<void> _getData() async {
|
||
final spi = state.spi;
|
||
final sid = spi.id;
|
||
|
||
if (!TryLimiter.canTry(sid)) {
|
||
if (state.conn != ServerConn.failed) {
|
||
updateConnection(ServerConn.failed);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final newStatus = state.status..err = null; // Clear previous error
|
||
updateStatus(newStatus);
|
||
|
||
if (state.conn < ServerConn.connecting || (state.client?.isClosed ?? true)) {
|
||
updateConnection(ServerConn.connecting);
|
||
|
||
// Wake on LAN
|
||
final wol = spi.wolCfg;
|
||
if (wol != null) {
|
||
try {
|
||
await wol.wake();
|
||
} catch (e) {
|
||
Loggers.app.warning('Wake on lan failed', e);
|
||
}
|
||
}
|
||
|
||
try {
|
||
final time1 = DateTime.now();
|
||
final client = await genClient(
|
||
spi,
|
||
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
|
||
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
|
||
);
|
||
updateClient(client);
|
||
|
||
final time2 = DateTime.now();
|
||
final spentTime = time2.difference(time1).inMilliseconds;
|
||
if (spi.jumpId == null) {
|
||
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
|
||
} else {
|
||
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
|
||
}
|
||
|
||
// Record successful connection
|
||
Stores.connectionStats.recordConnection(ConnectionStat(
|
||
serverId: spi.id,
|
||
serverName: spi.name,
|
||
timestamp: time1,
|
||
result: ConnectionResult.success,
|
||
durationMs: spentTime,
|
||
));
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.add(
|
||
id: sessionId,
|
||
spi: spi,
|
||
startTimeMs: time1.millisecondsSinceEpoch,
|
||
disconnect: () => ref.read(serversNotifierProvider.notifier).closeOneServer(spi.id),
|
||
status: TermSessionStatus.connecting,
|
||
);
|
||
TermSessionManager.setActive(sessionId, hasTerminal: false);
|
||
} catch (e) {
|
||
TryLimiter.inc(sid);
|
||
|
||
// Determine connection failure type
|
||
ConnectionResult failureResult;
|
||
if (e.toString().contains('timeout') || e.toString().contains('Timeout')) {
|
||
failureResult = ConnectionResult.timeout;
|
||
} else if (e.toString().contains('auth') || e.toString().contains('Authentication')) {
|
||
failureResult = ConnectionResult.authFailed;
|
||
} else if (e.toString().contains('network') || e.toString().contains('Network')) {
|
||
failureResult = ConnectionResult.networkError;
|
||
} else {
|
||
failureResult = ConnectionResult.unknownError;
|
||
}
|
||
|
||
// Record failed connection
|
||
Stores.connectionStats.recordConnection(ConnectionStat(
|
||
serverId: spi.id,
|
||
serverName: spi.name,
|
||
timestamp: DateTime.now(),
|
||
result: failureResult,
|
||
errorMessage: e.toString(),
|
||
durationMs: 0,
|
||
));
|
||
|
||
final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString());
|
||
updateStatus(newStatus);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
// Remove SSH session when connection fails
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.remove(sessionId);
|
||
|
||
Loggers.app.warning('Connect to ${spi.name} failed', e);
|
||
return;
|
||
}
|
||
|
||
updateConnection(ServerConn.connected);
|
||
|
||
// Update SSH session status to connected
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
|
||
|
||
try {
|
||
// Detect system type
|
||
final detectedSystemType = await SystemDetector.detect(state.client!, spi);
|
||
final newStatus = state.status..system = detectedSystemType;
|
||
updateStatus(newStatus);
|
||
|
||
final (_, writeScriptResult) = await state.client!.exec(
|
||
(session) async {
|
||
final scriptRaw = ShellFuncManager.allScript(
|
||
spi.custom?.cmds,
|
||
systemType: detectedSystemType,
|
||
disabledCmdTypes: spi.disabledCmdTypes,
|
||
).uint8List;
|
||
session.stdin.add(scriptRaw);
|
||
session.stdin.close();
|
||
},
|
||
entry: ShellFuncManager.getInstallShellCmd(
|
||
spi.id,
|
||
systemType: detectedSystemType,
|
||
customDir: spi.custom?.scriptDir,
|
||
),
|
||
);
|
||
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
|
||
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
|
||
throw writeScriptResult;
|
||
}
|
||
} on SSHAuthAbortError catch (e) {
|
||
TryLimiter.inc(sid);
|
||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||
final newStatus = state.status..err = err;
|
||
updateStatus(newStatus);
|
||
Loggers.app.warning(err);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
} on SSHAuthFailError catch (e) {
|
||
TryLimiter.inc(sid);
|
||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||
final newStatus = state.status..err = err;
|
||
updateStatus(newStatus);
|
||
Loggers.app.warning(err);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
} catch (e) {
|
||
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
|
||
final newStatus = state.status..err = err;
|
||
updateStatus(newStatus);
|
||
Loggers.app.warning(err);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
}
|
||
}
|
||
|
||
if (state.conn == ServerConn.connecting) return;
|
||
|
||
// Keep finished status to prevent UI from refreshing to loading state
|
||
if (state.conn != ServerConn.finished) {
|
||
updateConnection(ServerConn.loading);
|
||
}
|
||
|
||
List<String>? segments;
|
||
String? raw;
|
||
|
||
try {
|
||
final execResult = await state.client?.run(
|
||
ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir),
|
||
);
|
||
if (execResult != null) {
|
||
String? rawStr;
|
||
bool needGbk = false;
|
||
try {
|
||
rawStr = utf8.decode(execResult, allowMalformed: true);
|
||
// If there are unparseable characters, try fallback to GBK decoding
|
||
if (rawStr.contains('<EFBFBD>')) {
|
||
Loggers.app.warning('UTF8 decoding failed, use GBK decoding');
|
||
needGbk = true;
|
||
}
|
||
} catch (e) {
|
||
Loggers.app.warning('UTF8 decoding failed, use GBK decoding', e);
|
||
needGbk = true;
|
||
}
|
||
if (needGbk) {
|
||
try {
|
||
rawStr = gbk.decode(execResult);
|
||
} catch (e2) {
|
||
Loggers.app.warning('GBK decoding failed', e2);
|
||
rawStr = null;
|
||
}
|
||
}
|
||
if (rawStr == null) {
|
||
Loggers.app.warning('Decoding failed, execResult: $execResult');
|
||
}
|
||
raw = rawStr;
|
||
} else {
|
||
raw = execResult.toString();
|
||
}
|
||
|
||
if (raw == null || raw.isEmpty) {
|
||
TryLimiter.inc(sid);
|
||
final newStatus = state.status
|
||
..err = SSHErr(type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw');
|
||
updateStatus(newStatus);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
}
|
||
|
||
segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList();
|
||
if (raw.isEmpty || segments.isEmpty) {
|
||
if (Stores.setting.keepStatusWhenErr.fetch()) {
|
||
// Keep previous server status when error occurs
|
||
if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) {
|
||
return;
|
||
}
|
||
}
|
||
TryLimiter.inc(sid);
|
||
final newStatus = state.status
|
||
..err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
|
||
updateStatus(newStatus);
|
||
updateConnection(ServerConn.failed);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
TryLimiter.inc(sid);
|
||
final newStatus = state.status..err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
|
||
updateStatus(newStatus);
|
||
updateConnection(ServerConn.failed);
|
||
Loggers.app.warning('Get status from ${spi.name} failed', e);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Parse script output into command-specific mappings
|
||
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
|
||
|
||
final req = ServerStatusUpdateReq(
|
||
ss: state.status,
|
||
parsedOutput: parsedOutput,
|
||
system: state.status.system,
|
||
customCmds: spi.custom?.cmds ?? {},
|
||
);
|
||
final newStatus = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${spi.id}>');
|
||
updateStatus(newStatus);
|
||
} catch (e, trace) {
|
||
TryLimiter.inc(sid);
|
||
final newStatus = state.status
|
||
..err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
|
||
updateStatus(newStatus);
|
||
updateConnection(ServerConn.failed);
|
||
Loggers.app.warning('Server status', e, trace);
|
||
|
||
final sessionId = 'ssh_${spi.id}';
|
||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||
return;
|
||
}
|
||
|
||
// Set Server.isBusy to false each time this method is called
|
||
updateConnection(ServerConn.finished);
|
||
// Reset retry count only after successful preparation
|
||
TryLimiter.reset(sid);
|
||
}
|
||
}
|
||
|
||
extension IndividualServerStateExtension on ServerState {
|
||
bool get needGenClient => conn < ServerConn.connecting;
|
||
|
||
bool get canViewDetails => conn == ServerConn.finished;
|
||
|
||
String get id => spi.id;
|
||
}
|