mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-18 15:54:35 +01:00
feat: term session mgr (#846)
This commit is contained in:
@@ -20,6 +20,7 @@ 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._();
|
||||
@@ -183,6 +184,10 @@ class ServerProvider extends Provider {
|
||||
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();
|
||||
}
|
||||
@@ -209,6 +214,10 @@ class ServerProvider extends Provider {
|
||||
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) {
|
||||
@@ -229,10 +238,21 @@ class ServerProvider extends Provider {
|
||||
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();
|
||||
@@ -253,6 +273,11 @@ class ServerProvider extends Provider {
|
||||
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
|
||||
@@ -320,11 +345,26 @@ class ServerProvider extends Provider {
|
||||
} 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;
|
||||
@@ -332,6 +372,10 @@ class ServerProvider extends Provider {
|
||||
|
||||
_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);
|
||||
@@ -352,6 +396,10 @@ class ServerProvider extends Provider {
|
||||
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);
|
||||
@@ -359,6 +407,10 @@ class ServerProvider extends Provider {
|
||||
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.
|
||||
@@ -367,6 +419,10 @@ class ServerProvider extends Provider {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +452,10 @@ class ServerProvider extends Provider {
|
||||
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) {
|
||||
@@ -403,6 +463,10 @@ class ServerProvider extends Provider {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -422,6 +486,10 @@ class ServerProvider extends Provider {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
200
lib/data/ssh/session_manager.dart
Normal file
200
lib/data/ssh/session_manager.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:server_box/core/chan.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
|
||||
enum TermSessionStatus {
|
||||
connecting,
|
||||
connected,
|
||||
disconnected;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name.capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running SSH terminal session for Android notifications and iOS Live Activities.
|
||||
class TermSessionInfo {
|
||||
final String id;
|
||||
final String title; // e.g. server name
|
||||
final String subtitle; // e.g. user@ip:port
|
||||
final int startTimeMs;
|
||||
final TermSessionStatus status;
|
||||
|
||||
TermSessionInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.startTimeMs,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
'startTimeMs': startTimeMs,
|
||||
'status': status.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Singleton to track active SSH sessions and sync to Android notifications.
|
||||
abstract final class TermSessionManager {
|
||||
static final Map<String, _Entry> _entries = {};
|
||||
static String? _activeId; // For iOS Live Activity
|
||||
static Timer? _updateTimer; // Timer for iOS Live Activity updates
|
||||
static const _updateInterval = Duration(seconds: 5); // 5-second update interval
|
||||
|
||||
static void init() {
|
||||
if (isAndroid) {
|
||||
MethodChans.registerHandler((id) async {
|
||||
_entries[id]?.disconnect?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a session record and push update to Android.
|
||||
static void add({
|
||||
required String id,
|
||||
required Spi spi,
|
||||
required int startTimeMs,
|
||||
required VoidCallback disconnect,
|
||||
TermSessionStatus status = TermSessionStatus.connecting,
|
||||
}) {
|
||||
final info = TermSessionInfo(
|
||||
id: id,
|
||||
title: spi.name,
|
||||
subtitle: spi.oldId,
|
||||
startTimeMs: startTimeMs,
|
||||
status: status,
|
||||
);
|
||||
_entries[id] = _Entry(info, disconnect, hasTerminalUI: true);
|
||||
_activeId = id; // most recent as active
|
||||
_sync();
|
||||
}
|
||||
|
||||
static void updateStatus(String id, TermSessionStatus status) {
|
||||
final old = _entries[id];
|
||||
if (old == null) return;
|
||||
_entries[id] = _Entry(
|
||||
TermSessionInfo(
|
||||
id: old.info.id,
|
||||
title: old.info.title,
|
||||
subtitle: old.info.subtitle,
|
||||
startTimeMs: old.info.startTimeMs,
|
||||
status: status,
|
||||
),
|
||||
old.disconnect,
|
||||
hasTerminalUI: old.hasTerminalUI,
|
||||
);
|
||||
_sync();
|
||||
}
|
||||
|
||||
static void remove(String id) {
|
||||
_entries.remove(id);
|
||||
if (_activeId == id) {
|
||||
_activeId = _entries.keys.firstOrNull;
|
||||
}
|
||||
_sync();
|
||||
}
|
||||
|
||||
static Future<void> _sync() async {
|
||||
// Android: update foreground service notifications
|
||||
if (isAndroid) {
|
||||
final isRunning = await MethodChans.isServiceRunning();
|
||||
if (_entries.isEmpty) {
|
||||
if (isRunning) {
|
||||
MethodChans.stopService();
|
||||
}
|
||||
await MethodChans.updateSessions(jsonEncode({'sessions': []}));
|
||||
} else {
|
||||
if (!isRunning) {
|
||||
MethodChans.startService();
|
||||
}
|
||||
final payload = jsonEncode({'sessions': _entries.values.map((e) => e.info.toJson()).toList()});
|
||||
await MethodChans.updateSessions(payload);
|
||||
}
|
||||
}
|
||||
|
||||
// iOS: manage Live Activity timer
|
||||
if (isIOS) {
|
||||
if (_entries.isEmpty) {
|
||||
_updateTimer?.cancel();
|
||||
_updateTimer = null;
|
||||
await MethodChans.stopLiveActivity();
|
||||
} else {
|
||||
// Start timer if not already running
|
||||
_updateTimer ??= Timer.periodic(_updateInterval, (_) => _updateLiveActivity());
|
||||
// Immediately update for immediate feedback
|
||||
await _updateLiveActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _updateLiveActivity() async {
|
||||
if (!isIOS || _entries.isEmpty) return;
|
||||
|
||||
final connectionCount = _entries.length;
|
||||
|
||||
if (connectionCount == 1) {
|
||||
// Single connection: show hostname
|
||||
final id = _activeId ?? _entries.keys.first;
|
||||
final entry = _entries[id];
|
||||
if (entry == null) return;
|
||||
final payload = jsonEncode({
|
||||
...entry.info.toJson(),
|
||||
'hasTerminal': entry.hasTerminalUI,
|
||||
'connectionCount': connectionCount,
|
||||
});
|
||||
await MethodChans.updateLiveActivity(payload);
|
||||
} else {
|
||||
// Multiple connections: show connection count
|
||||
final id = _activeId ?? _entries.keys.first;
|
||||
final entry = _entries[id];
|
||||
if (entry == null) return;
|
||||
final payload = jsonEncode({
|
||||
'id': 'multi_connections',
|
||||
'title': '$connectionCount connections',
|
||||
'subtitle': 'Multiple SSH sessions active',
|
||||
'startTimeMs': entry.info.startTimeMs,
|
||||
'status': TermSessionStatus.connected.toString(),
|
||||
'hasTerminal': entry.hasTerminalUI,
|
||||
'connectionCount': connectionCount,
|
||||
});
|
||||
await MethodChans.updateLiveActivity(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark which session is actively displayed in UI (for iOS Live Activity).
|
||||
static void setActive(String id, {bool hasTerminal = true}) {
|
||||
_activeId = id;
|
||||
final old = _entries[id];
|
||||
if (old != null) {
|
||||
_entries[id] = _Entry(old.info, old.disconnect, hasTerminalUI: hasTerminal);
|
||||
_sync();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop Live Activity when app is closed/terminated (iOS only).
|
||||
static Future<void> stopLiveActivityOnAppClose() async {
|
||||
if (!isIOS) return;
|
||||
|
||||
// Cancel any running timers
|
||||
_updateTimer?.cancel();
|
||||
_updateTimer = null;
|
||||
|
||||
// Stop the Live Activity
|
||||
await MethodChans.stopLiveActivity();
|
||||
}
|
||||
}
|
||||
|
||||
class _Entry {
|
||||
final TermSessionInfo info;
|
||||
final VoidCallback? disconnect;
|
||||
final bool hasTerminalUI;
|
||||
_Entry(this.info, this.disconnect, {this.hasTerminalUI = true});
|
||||
}
|
||||
Reference in New Issue
Block a user