feat: term session mgr (#846)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-12 23:43:42 +08:00
committed by GitHub
parent 584af5423a
commit 9b01da5a23
39 changed files with 1275 additions and 55 deletions

View File

@@ -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;
}

View 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});
}