Files
flutter_server_box/lib/data/ssh/session_manager.dart
2025-09-06 14:04:53 +08:00

220 lines
6.1 KiB
Dart

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();
},
() {
// Stop all connections when notification "Stop All" is pressed
stopAllConnections();
},
);
}
}
/// Called when Android notification "Stop All" button is pressed
static void stopAllConnections() {
// Disconnect all sessions
final disconnectCallbacks = _entries.values.map((e) => e.disconnect).where((cb) => cb != null).toList();
for (final disconnect in disconnectCallbacks) {
disconnect!();
}
// Clear all entries
_entries.clear();
_activeId = null;
_sync();
}
/// 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});
}