You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
459 lines
14 KiB
459 lines
14 KiB
|
1 month ago
|
using System;
|
||
|
|
using System.Collections.ObjectModel;
|
||
|
|
using System.Linq;
|
||
|
|
using System.Reactive;
|
||
|
|
using System.Reactive.Disposables;
|
||
|
|
using System.Reactive.Linq;
|
||
|
|
using System.Threading;
|
||
|
|
using System.Threading.Tasks;
|
||
|
|
using AuroraDesk.Core.Entities;
|
||
|
|
using AuroraDesk.Core.Interfaces;
|
||
|
|
using AuroraDesk.Presentation.ViewModels.Base;
|
||
|
|
using Microsoft.Extensions.Logging;
|
||
|
|
using ReactiveUI;
|
||
|
|
using Avalonia.Threading;
|
||
|
|
|
||
|
|
namespace AuroraDesk.Presentation.ViewModels.Pages;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Plink 多客户端实时交互页面 ViewModel
|
||
|
|
/// </summary>
|
||
|
|
public sealed class PlinkPageViewModel : RoutableViewModel, IDisposable
|
||
|
|
{
|
||
|
|
private readonly IPlinkSessionService _plinkSessionService;
|
||
|
|
private readonly ILogger<PlinkPageViewModel>? _logger;
|
||
|
|
private readonly ObservableCollection<PlinkSessionItemViewModel> _sessions = new();
|
||
|
|
|
||
|
|
private PlinkSessionItemViewModel? _selectedSession;
|
||
|
|
private string _host = "127.0.0.1";
|
||
|
|
private int _port = 22;
|
||
|
|
private string _userName = "root";
|
||
|
|
private string? _password;
|
||
|
|
private string? _displayName;
|
||
|
|
private string? _privateKeyPath;
|
||
|
|
private string? _additionalArguments;
|
||
|
|
private string? _plinkExecutablePath;
|
||
|
|
private bool _allowAnyHostKey = true;
|
||
|
|
private bool _isBusy;
|
||
|
|
private string? _statusMessage;
|
||
|
|
private PlinkAuthenticationMode _selectedAuthenticationMode = PlinkAuthenticationMode.Password;
|
||
|
|
|
||
|
|
public PlinkPageViewModel(
|
||
|
|
IScreen hostScreen,
|
||
|
|
IPlinkSessionService plinkSessionService,
|
||
|
|
ILogger<PlinkPageViewModel>? logger = null)
|
||
|
|
: base(hostScreen, "plink-manager")
|
||
|
|
{
|
||
|
|
_plinkSessionService = plinkSessionService;
|
||
|
|
_logger = logger;
|
||
|
|
|
||
|
|
AddSessionCommand = ReactiveCommand.CreateFromTask(AddSessionAsync,
|
||
|
|
this.WhenAnyValue(x => x.IsBusy, busy => !busy));
|
||
|
|
|
||
|
|
RemoveSessionCommand = ReactiveCommand.CreateFromTask<PlinkSessionItemViewModel>(RemoveSessionAsync);
|
||
|
|
|
||
|
|
SelectPasswordAuthCommand = ReactiveCommand.Create(() =>
|
||
|
|
{
|
||
|
|
SelectedAuthenticationMode = PlinkAuthenticationMode.Password;
|
||
|
|
});
|
||
|
|
SelectPrivateKeyAuthCommand = ReactiveCommand.Create(() =>
|
||
|
|
{
|
||
|
|
SelectedAuthenticationMode = PlinkAuthenticationMode.PrivateKey;
|
||
|
|
});
|
||
|
|
|
||
|
|
RefreshSnapshotCommand = ReactiveCommand.Create(RefreshSnapshot);
|
||
|
|
|
||
|
|
RefreshSnapshot();
|
||
|
|
}
|
||
|
|
|
||
|
|
public ObservableCollection<PlinkSessionItemViewModel> Sessions => _sessions;
|
||
|
|
|
||
|
|
public PlinkSessionItemViewModel? SelectedSession
|
||
|
|
{
|
||
|
|
get => _selectedSession;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _selectedSession, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string Host
|
||
|
|
{
|
||
|
|
get => _host;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _host, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public int Port
|
||
|
|
{
|
||
|
|
get => _port;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _port, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string UserName
|
||
|
|
{
|
||
|
|
get => _userName;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _userName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? Password
|
||
|
|
{
|
||
|
|
get => _password;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _password, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? DisplayName
|
||
|
|
{
|
||
|
|
get => _displayName;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _displayName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? PrivateKeyPath
|
||
|
|
{
|
||
|
|
get => _privateKeyPath;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _privateKeyPath, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? AdditionalArguments
|
||
|
|
{
|
||
|
|
get => _additionalArguments;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _additionalArguments, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? PlinkExecutablePath
|
||
|
|
{
|
||
|
|
get => _plinkExecutablePath;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _plinkExecutablePath, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool AllowAnyHostKey
|
||
|
|
{
|
||
|
|
get => _allowAnyHostKey;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _allowAnyHostKey, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsBusy
|
||
|
|
{
|
||
|
|
get => _isBusy;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _isBusy, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? StatusMessage
|
||
|
|
{
|
||
|
|
get => _statusMessage;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public PlinkAuthenticationMode SelectedAuthenticationMode
|
||
|
|
{
|
||
|
|
get => _selectedAuthenticationMode;
|
||
|
|
private set
|
||
|
|
{
|
||
|
|
if (_selectedAuthenticationMode == value)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.RaiseAndSetIfChanged(ref _selectedAuthenticationMode, value);
|
||
|
|
this.RaisePropertyChanged(nameof(IsPasswordMode));
|
||
|
|
this.RaisePropertyChanged(nameof(IsPrivateKeyMode));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsPasswordMode => SelectedAuthenticationMode == PlinkAuthenticationMode.Password;
|
||
|
|
|
||
|
|
public bool IsPrivateKeyMode => SelectedAuthenticationMode == PlinkAuthenticationMode.PrivateKey;
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> AddSessionCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<PlinkSessionItemViewModel, Unit> RemoveSessionCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SelectPasswordAuthCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SelectPrivateKeyAuthCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> RefreshSnapshotCommand { get; }
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
foreach (var session in _sessions.ToArray())
|
||
|
|
{
|
||
|
|
session.Dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task AddSessionAsync()
|
||
|
|
{
|
||
|
|
PlinkSessionItemViewModel? sessionViewModel = null;
|
||
|
|
try
|
||
|
|
{
|
||
|
|
IsBusy = true;
|
||
|
|
StatusMessage = "正在创建新会话...";
|
||
|
|
|
||
|
|
var options = new PlinkSessionOptions(
|
||
|
|
Host,
|
||
|
|
Port,
|
||
|
|
UserName,
|
||
|
|
IsPasswordMode ? Password : null,
|
||
|
|
IsPrivateKeyMode ? PrivateKeyPath : null,
|
||
|
|
DisplayName,
|
||
|
|
AdditionalArguments,
|
||
|
|
PlinkExecutablePath,
|
||
|
|
AllowAnyHostKey);
|
||
|
|
|
||
|
|
var info = await _plinkSessionService.StartSessionAsync(options, CancellationToken.None).ConfigureAwait(false);
|
||
|
|
|
||
|
|
sessionViewModel = new PlinkSessionItemViewModel(info, _plinkSessionService, _logger);
|
||
|
|
sessionViewModel.Initialize();
|
||
|
|
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_sessions.Add(sessionViewModel);
|
||
|
|
SelectedSession = sessionViewModel;
|
||
|
|
});
|
||
|
|
|
||
|
|
await Task.Delay(TimeSpan.FromSeconds(1.5)).ConfigureAwait(false);
|
||
|
|
|
||
|
|
var currentStatus = await Dispatcher.UIThread.InvokeAsync(() => sessionViewModel.Status);
|
||
|
|
if (currentStatus is PlinkSessionStatus.Error or PlinkSessionStatus.Disconnected)
|
||
|
|
{
|
||
|
|
var latestError = await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
return sessionViewModel.Messages.LastOrDefault(m => m.IsError)?.Content;
|
||
|
|
});
|
||
|
|
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_sessions.Remove(sessionViewModel);
|
||
|
|
if (ReferenceEquals(SelectedSession, sessionViewModel))
|
||
|
|
{
|
||
|
|
SelectedSession = _sessions.LastOrDefault();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
sessionViewModel.Dispose();
|
||
|
|
|
||
|
|
StatusMessage = string.IsNullOrWhiteSpace(latestError)
|
||
|
|
? "创建会话失败:未能保持连接。"
|
||
|
|
: $"创建会话失败:{latestError}";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
StatusMessage = $"会话 {sessionViewModel.DisplayName} 已创建。";
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
StatusMessage = $"创建会话失败: {ex.Message}";
|
||
|
|
_logger?.LogError(ex, "创建 Plink 会话失败。");
|
||
|
|
sessionViewModel?.Dispose();
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
IsBusy = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task RemoveSessionAsync(PlinkSessionItemViewModel? session)
|
||
|
|
{
|
||
|
|
if (session is null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
_logger?.LogInformation("准备移除 Plink 会话 {SessionId}", session.SessionId);
|
||
|
|
await _plinkSessionService.StopSessionAsync(session.SessionId).ConfigureAwait(false);
|
||
|
|
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_sessions.Remove(session);
|
||
|
|
if (ReferenceEquals(SelectedSession, session))
|
||
|
|
{
|
||
|
|
SelectedSession = _sessions.LastOrDefault();
|
||
|
|
}
|
||
|
|
|
||
|
|
session.Dispose();
|
||
|
|
StatusMessage = $"会话 {session.DisplayName} 已移除。";
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private void RefreshSnapshot()
|
||
|
|
{
|
||
|
|
var snapshot = _plinkSessionService.GetSessions();
|
||
|
|
|
||
|
|
foreach (var session in _sessions.ToArray())
|
||
|
|
{
|
||
|
|
if (snapshot.All(x => x.SessionId != session.SessionId))
|
||
|
|
{
|
||
|
|
session.Dispose();
|
||
|
|
_sessions.Remove(session);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var info in snapshot)
|
||
|
|
{
|
||
|
|
if (_sessions.All(x => x.SessionId != info.SessionId))
|
||
|
|
{
|
||
|
|
var vm = new PlinkSessionItemViewModel(info, _plinkSessionService, _logger);
|
||
|
|
vm.Initialize();
|
||
|
|
_sessions.Add(vm);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (SelectedSession == null || !_sessions.Contains(SelectedSession))
|
||
|
|
{
|
||
|
|
SelectedSession = _sessions.FirstOrDefault();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 表示单个 Plink 会话的视图模型
|
||
|
|
/// </summary>
|
||
|
|
public sealed class PlinkSessionItemViewModel : ReactiveObject, IDisposable
|
||
|
|
{
|
||
|
|
private readonly IPlinkSessionService _plinkSessionService;
|
||
|
|
private readonly ILogger? _logger;
|
||
|
|
private readonly CompositeDisposable _disposables = new();
|
||
|
|
private readonly ObservableCollection<PlinkMessageEntry> _messages = new();
|
||
|
|
|
||
|
|
private string _displayName;
|
||
|
|
private PlinkSessionStatus _status;
|
||
|
|
private string _outgoingText = string.Empty;
|
||
|
|
private DateTime _createdAtUtc;
|
||
|
|
|
||
|
|
public PlinkSessionItemViewModel(PlinkSessionInfo info, IPlinkSessionService plinkSessionService, ILogger? logger = null)
|
||
|
|
{
|
||
|
|
_plinkSessionService = plinkSessionService;
|
||
|
|
_logger = logger;
|
||
|
|
SessionId = info.SessionId;
|
||
|
|
_displayName = info.DisplayName;
|
||
|
|
_status = info.Status;
|
||
|
|
_createdAtUtc = info.CreatedAtUtc;
|
||
|
|
|
||
|
|
SendCommand = ReactiveCommand.CreateFromTask(SendAsync,
|
||
|
|
this.WhenAnyValue(x => x.Status, x => x.OutgoingText,
|
||
|
|
(status, text) => status == PlinkSessionStatus.Connected && !string.IsNullOrWhiteSpace(text)));
|
||
|
|
|
||
|
|
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync,
|
||
|
|
this.WhenAnyValue(x => x.Status, status => status is PlinkSessionStatus.Connected or PlinkSessionStatus.Connecting));
|
||
|
|
}
|
||
|
|
|
||
|
|
public Guid SessionId { get; }
|
||
|
|
|
||
|
|
public ObservableCollection<PlinkMessageEntry> Messages => _messages;
|
||
|
|
|
||
|
|
public string DisplayName
|
||
|
|
{
|
||
|
|
get => _displayName;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _displayName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public PlinkSessionStatus Status
|
||
|
|
{
|
||
|
|
get => _status;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _status, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string OutgoingText
|
||
|
|
{
|
||
|
|
get => _outgoingText;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _outgoingText, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public DateTime CreatedAtUtc
|
||
|
|
{
|
||
|
|
get => _createdAtUtc;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _createdAtUtc, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsConnected => Status == PlinkSessionStatus.Connected;
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SendCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
|
||
|
|
|
||
|
|
public void Initialize()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var statusSubscription = _plinkSessionService
|
||
|
|
.ObserveStatus(SessionId)
|
||
|
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||
|
|
.Subscribe(status =>
|
||
|
|
{
|
||
|
|
Status = status;
|
||
|
|
});
|
||
|
|
|
||
|
|
_disposables.Add(statusSubscription);
|
||
|
|
|
||
|
|
var messageSubscription = _plinkSessionService
|
||
|
|
.ObserveMessages(SessionId)
|
||
|
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||
|
|
.Subscribe(message =>
|
||
|
|
{
|
||
|
|
var entry = PlinkMessageEntry.FromMessage(message);
|
||
|
|
_messages.Add(entry);
|
||
|
|
});
|
||
|
|
|
||
|
|
_disposables.Add(messageSubscription);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogError(ex, "订阅 Plink 会话 {SessionId} 流失败。", SessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task SendAsync()
|
||
|
|
{
|
||
|
|
var text = OutgoingText;
|
||
|
|
if (string.IsNullOrWhiteSpace(text))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var result = await _plinkSessionService.SendAsync(SessionId, text, CancellationToken.None).ConfigureAwait(false);
|
||
|
|
if (result)
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
OutgoingText = string.Empty;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private Task DisconnectAsync()
|
||
|
|
{
|
||
|
|
return _plinkSessionService.StopSessionAsync(SessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
_disposables.Dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// UI 消息项
|
||
|
|
/// </summary>
|
||
|
|
public sealed record PlinkMessageEntry(
|
||
|
|
Guid SessionId,
|
||
|
|
string Timestamp,
|
||
|
|
PlinkMessageDirection Direction,
|
||
|
|
string Content,
|
||
|
|
bool IsError)
|
||
|
|
{
|
||
|
|
public static PlinkMessageEntry FromMessage(PlinkMessage message)
|
||
|
|
{
|
||
|
|
var timestamp = message.Timestamp.ToLocalTime().ToString("HH:mm:ss");
|
||
|
|
return new PlinkMessageEntry(
|
||
|
|
message.SessionId,
|
||
|
|
timestamp,
|
||
|
|
message.Direction,
|
||
|
|
message.Content,
|
||
|
|
message.IsError);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public enum PlinkAuthenticationMode
|
||
|
|
{
|
||
|
|
Password,
|
||
|
|
PrivateKey
|
||
|
|
}
|
||
|
|
|