import 'dart:async'; import 'dart:convert'; // import 'dart:io'; 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:server_box/core/extension/ssh_client.dart'; import 'package:server_box/core/sync.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/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/res/status.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/session_manager.dart'; class ServerProvider extends Provider { const ServerProvider._(); static const instance = ServerProvider._(); static final Map> servers = {}; static final serverOrder = [].vn; static final _tags = {}.vn; static VNode> get tags => _tags; static Timer? _timer; static final _manualDisconnectedIds = {}; static final _serverIdsUpdating = ?>{}; @override Future load() async { super.load(); // #147 // Clear all servers because of restarting app will cause duplicate servers final oldServers = Map>.from(servers); servers.clear(); serverOrder.value.clear(); final spis = Stores.server.fetch(); for (int idx = 0; idx < spis.length; idx++) { final spi = spis[idx]; final originServer = oldServers[spi.id]; /// #258 /// If not [shouldReconnect], then keep the old state. if (originServer != null && !originServer.value.spi.shouldReconnect(spi)) { originServer.value.spi = spi; servers[spi.id] = originServer; } else { final newServer = genServer(spi); servers[spi.id] = newServer.vn; } } final serverOrder_ = Stores.setting.serverOrder.fetch(); if (serverOrder_.isNotEmpty) { spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id); serverOrder.value.addAll(spis.map((e) => e.id)); } else { serverOrder.value.addAll(servers.keys); } // Must use [equals] to compare [Order] here. if (!serverOrder.value.equals(serverOrder_)) { Stores.setting.serverOrder.put(serverOrder.value); } _updateTags(); // Must notify here, or the UI will not be updated. serverOrder.notify(); } /// Get a [Server] by [spi] or [id]. /// /// Priority: [spi] > [id] static VNode? pick({Spi? spi, String? id}) { if (spi != null) { return servers[spi.id]; } if (id != null) { return servers[id]; } return null; } static void _updateTags() { final tags = {}; for (final s in servers.values) { final spiTags = s.value.spi.tags; if (spiTags == null) continue; for (final t in spiTags) { tags.add(t); } } _tags.value = tags; } static Server genServer(Spi spi) { return Server(spi, InitStatus.status, ServerConn.disconnected); } /// if [spi] is specificed then only refresh this server /// [onlyFailed] only refresh failed servers static Future refresh({Spi? spi, bool onlyFailed = false}) async { if (spi != null) { _manualDisconnectedIds.remove(spi.id); await _getData(spi); return; } await Future.wait( servers.values.map((val) async { final s = val.value; if (onlyFailed) { if (s.conn != ServerConn.failed) return; TryLimiter.reset(s.spi.id); } if (_manualDisconnectedIds.contains(s.spi.id)) return; if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) { return; } // Check if already updating, and if so, wait for it to complete final existingUpdate = _serverIdsUpdating[s.spi.id]; if (existingUpdate != null) { // Already updating, wait for the existing update to complete try { await existingUpdate; } catch (e) { // Ignore errors from the existing update, we'll try our own } return; } // Start a new update operation final updateFuture = _updateServer(s.spi); _serverIdsUpdating[s.spi.id] = updateFuture; try { await updateFuture; } finally { _serverIdsUpdating.remove(s.spi.id); } }), ); } static Future _updateServer(Spi spi) async { await _getData(spi); } static Future startAutoRefresh() async { var duration = Stores.setting.serverStatusUpdateInterval.fetch(); stopAutoRefresh(); if (duration == 0) return; if (duration < 0 || duration > 10 || duration == 1) { duration = 3; Loggers.app.warning('Invalid duration: $duration, use default 3'); } _timer = Timer.periodic(Duration(seconds: duration), (_) async { await refresh(); }); } static void stopAutoRefresh() { if (_timer != null) { _timer!.cancel(); _timer = null; } } static bool get isAutoRefreshOn => _timer != null; static void setDisconnected() { for (final s in servers.values) { s.value.conn = ServerConn.disconnected; s.notify(); // Update SSH session status to disconnected final sessionId = 'ssh_${s.value.spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); } //TryLimiter.clear(); } static void closeServer({String? id}) { if (id == null) { for (final s in servers.values) { _closeOneServer(s.value.spi.id); } return; } _closeOneServer(id); } static void _closeOneServer(String id) { final s = servers[id]; if (s == null) { Loggers.app.warning('Server with id $id not found'); return; } final item = s.value; item.client?.close(); item.client = null; item.conn = ServerConn.disconnected; _manualDisconnectedIds.add(id); s.notify(); // Remove SSH session when server is manually closed final sessionId = 'ssh_$id'; TermSessionManager.remove(sessionId); } static void addServer(Spi spi) { servers[spi.id] = genServer(spi).vn; Stores.server.put(spi); serverOrder.value.add(spi.id); serverOrder.notify(); Stores.setting.serverOrder.put(serverOrder.value); _updateTags(); refresh(spi: spi); bakSync.sync(milliDelay: 1000); } static void delServer(String id) { servers.remove(id); serverOrder.value.remove(id); serverOrder.notify(); Stores.setting.serverOrder.put(serverOrder.value); Stores.server.delete(id); _updateTags(); // Remove SSH session when server is deleted final sessionId = 'ssh_$id'; TermSessionManager.remove(sessionId); bakSync.sync(milliDelay: 1000); } static void deleteAll() { // Remove all SSH sessions before clearing servers for (final id in servers.keys) { final sessionId = 'ssh_$id'; TermSessionManager.remove(sessionId); } servers.clear(); serverOrder.value.clear(); serverOrder.notify(); Stores.setting.serverOrder.put(serverOrder.value); Stores.server.clear(); _updateTags(); bakSync.sync(milliDelay: 1000); } static Future updateServer(Spi old, Spi newSpi) async { if (old != newSpi) { Stores.server.update(old, newSpi); servers[old.id]?.value.spi = newSpi; if (newSpi.id != old.id) { servers[newSpi.id] = servers[old.id]!; servers.remove(old.id); serverOrder.value.update(old.id, newSpi.id); Stores.setting.serverOrder.put(serverOrder.value); serverOrder.notify(); // Update SSH session ID when server ID changes final oldSessionId = 'ssh_${old.id}'; TermSessionManager.remove(oldSessionId); // Session will be re-added when reconnecting if necessary } // Only reconnect if neccessary if (newSpi.shouldReconnect(old)) { // Use [newSpi.id] instead of [old.id] because [old.id] may be changed TryLimiter.reset(newSpi.id); refresh(spi: newSpi); } } _updateTags(); bakSync.sync(milliDelay: 1000); } static void _setServerState(VNode s, ServerConn ss) { s.value.conn = ss; s.notify(); } static Future _getData(Spi spi) async { final sid = spi.id; final s = servers[sid]; if (s == null) return; final sv = s.value; if (!TryLimiter.canTry(sid)) { if (sv.conn != ServerConn.failed) { _setServerState(s, ServerConn.failed); } return; } sv.status.err = null; if (sv.needGenClient || (sv.client?.isClosed ?? true)) { _setServerState(s, ServerConn.connecting); final wol = spi.wolCfg; if (wol != null) { try { await wol.wake(); } catch (e) { // TryLimiter.inc(sid); // s.status.err = SSHErr( // type: SSHErrType.connect, // message: 'Wake on lan failed: $e', // ); // _setServerState(s, ServerConn.failed); Loggers.app.warning('Wake on lan failed', e); // return; } } try { final time1 = DateTime.now(); sv.client = await genClient( spi, timeout: Duration(seconds: Stores.setting.timeout.fetch()), onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi), ); 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.'); } // Add SSH session to TermSessionManager final sessionId = 'ssh_${spi.id}'; TermSessionManager.add( id: sessionId, spi: spi, startTimeMs: time1.millisecondsSinceEpoch, disconnect: () => _closeOneServer(spi.id), status: TermSessionStatus.connecting, ); TermSessionManager.setActive(sessionId, hasTerminal: false); } catch (e) { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString()); _setServerState(s, ServerConn.failed); // Remove SSH session on connection failure final sessionId = 'ssh_${spi.id}'; TermSessionManager.remove(sessionId); /// In order to keep privacy, print [spi.name] instead of [spi.id] Loggers.app.warning('Connect to ${spi.name} failed', e); return; } _setServerState(s, ServerConn.connected); // Update SSH session status to connected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected); try { // Detect system type using helper final detectedSystemType = await SystemDetector.detect(sv.client!, spi); sv.status.system = detectedSystemType; final (_, writeScriptResult) = await sv.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)); 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()); sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); // Update SSH session status to disconnected 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()); sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); // Update SSH session status to disconnected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } catch (e) { // If max try times < 2 and can't write script, this will stop the status getting and etc. // TryLimiter.inc(sid); final err = SSHErr(type: SSHErrType.writeScript, message: e.toString()); sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); // Update SSH session status to disconnected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); } } if (sv.conn == ServerConn.connecting) return; /// Keep [finished] state, or the UI will be refreshed to [loading] state /// instead of the '$Temp | $Uptime'. /// eg: '32C | 7 days' if (sv.conn != ServerConn.finished) { _setServerState(s, ServerConn.loading); } List? segments; String? raw; try { final execResult = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)); if (execResult != null) { String? rawStr; bool needGbk = false; try { rawStr = utf8.decode(execResult, allowMalformed: true); // If there are characters that cannot be parsed, try to fallback to gbk decoding if (rawStr.contains('�')) { 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); sv.status.err = SSHErr( type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw', ); _setServerState(s, ServerConn.failed); // Update SSH session status to disconnected on segments error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } //dprint('Get status from ${spi.name}:\n$raw'); 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 err occurs if (sv.conn != ServerConn.failed && sv.status.more.isNotEmpty) { return; } } TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw'); _setServerState(s, ServerConn.failed); // Update SSH session status to disconnected on segments error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } } catch (e) { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString()); _setServerState(s, ServerConn.failed); Loggers.app.warning('Get status from ${spi.name} failed', e); // Update SSH session status to disconnected on status error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } try { // Parse script output into command-specific map final parsedOutput = ScriptConstants.parseScriptOutput(raw); final req = ServerStatusUpdateReq( ss: sv.status, parsedOutput: parsedOutput, system: sv.status.system, customCmds: spi.custom?.cmds ?? {}, ); sv.status = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${sv.id}>'); } catch (e, trace) { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw'); _setServerState(s, ServerConn.failed); Loggers.app.warning('Server status', e, trace); // Update SSH session status to disconnected on parse error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } /// Call this every time for setting [Server.isBusy] to false _setServerState(s, ServerConn.finished); // reset try times only after prepared successfully TryLimiter.reset(sid); } }