using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Data;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using XFEToolBox.Client.Utilities;
namespace XFEToolBox.Tools.SpaceEngineers.ViewModels;
public sealed partial class LaunchArgument : ObservableObject
{
[ObservableProperty] private string value = "";
}
public sealed record BackupItem(string Path)
{
public string Name => System.IO.Path.GetFileName(Path);
}
public sealed partial class MainPageViewModel : ObservableObject
{
private readonly ManagerService service;
private readonly Dispatcher dispatcher;
private readonly bool pollingEnabled;
private readonly SemaphoreSlim operationGate = new(1, 1);
private readonly CancellationTokenSource lifetime = new();
private readonly Queue<string> logLines = new();
private CancellationTokenSource? operationCancellation;
private Task? pollingTask;
private bool initialized, shuttingDown;
private string lastPollError = "";
public ObservableCollection<PluginRow> Plugins { get; } = [];
public ObservableCollection<LaunchArgument> Arguments { get; } = [];
public ObservableCollection<BackupItem> Backups { get; } = [];
public ICollectionView PluginsView { get; }
public string[] PluginFilters { get; } = ["全部插件", "已启用", "已停用"];
[ObservableProperty] private int selectedTab;
[ObservableProperty] private int selectedPluginFilter;
[ObservableProperty] private string filter = "";
[ObservableProperty] private string bin64Path = "";
[ObservableProperty] private string launcherPath = "";
[ObservableProperty] private string profilePath = "";
[ObservableProperty] private string closeTimeoutText = "120";
[ObservableProperty] private bool autoRestartAfterApply;
[ObservableProperty] private bool restartOnLocalChanges;
[ObservableProperty] private bool isBusy;
[ObservableProperty] private bool isPolling;
[ObservableProperty] private bool isRunning;
[ObservableProperty] private bool isDirty;
[ObservableProperty] private PluginRow? selectedPlugin;
[ObservableProperty] private BackupItem? selectedBackup;
[ObservableProperty] private string processStatus = "正在读取游戏状态…";
[ObservableProperty] private string loaderStatus = "请配置游戏与插件加载器。";
[ObservableProperty] private string status = "准备读取插件配置。";
[ObservableProperty] private string logText = "";
public bool CanEdit => !IsBusy && !IsPolling && !shuttingDown;
public bool CanLaunch => CanEdit && !IsRunning;
public bool CanClose => CanEdit && IsRunning;
public bool CanApply => CanEdit && IsDirty;
public bool CanRemove => CanEdit && SelectedPlugin is not null;
public bool CanRestore => CanEdit && SelectedBackup is not null;
public bool HasSelectedPlugin => SelectedPlugin is not null;
public bool ShowEmptyPlugins => PluginsView.IsEmpty;
public string EmptyPluginsTitle => Plugins.Count == 0 ? "还没有插件" : "没有匹配的插件";
public string EmptyPluginsHint => Plugins.Count == 0 ? "添加本地插件 DLL 或包含已编译插件的目录,然后应用更改。首次使用请在「启动与环境」构建 XFE 加载器。" : "试试其他关键词,或切换到「全部插件」。";
public string PluginSummary => $"{Plugins.Count} 个插件 · {Plugins.Count(p => p.IsEnabled)} 个启用" + (IsDirty ? " · 有待应用更改" : "");
public string ApplyHint => AutoRestartAfterApply ? "应用后自动正常重启运行中的游戏;请先保存存档。" : IsRunning ? "游戏运行中。请先正常退出,或在「启动与环境」启用应用后自动重启。" : "插件更改在下次启动游戏时生效。";
public string BackupSummary => Backups.Count == 0 ? "当前配置还没有备份。首次应用已有配置时会自动创建。" : $"发现 {Backups.Count} 份配置备份。恢复后是否重启遵循自动重启设置。";
public MainPageViewModel() : this(new ManagerService()) { }
public MainPageViewModel(ManagerService service, bool pollingEnabled = true)
{
this.service = service;
this.pollingEnabled = pollingEnabled;
dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
PluginsView = CollectionViewSource.GetDefaultView(Plugins);
PluginsView.Filter = MatchPlugin;
service.Progress += OnProgress;
ReadSettings();
}
public async Task InitializeAsync()
{
if (initialized || shuttingDown) return;
initialized = true;
await RunAsync("读取游戏与插件配置", async token => await RefreshCoreAsync(token));
if (pollingEnabled && !shuttingDown) pollingTask = PollLoopAsync();
}
private bool MatchPlugin(object item)
{
if (item is not PluginRow plugin) return false;
bool category = SelectedPluginFilter switch
{
1 => plugin.IsEnabled,
2 => !plugin.IsEnabled,
_ => true
};
return category && (string.IsNullOrWhiteSpace(Filter) || Contains(plugin.Name, Filter) || Contains(plugin.Id, Filter) || Contains(plugin.Location, Filter) || Contains(plugin.SourcePath, Filter));
}
private static bool Contains(string? text, string search) => text?.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase) == true;
partial void OnFilterChanged(string value) => RefreshPluginView();
partial void OnSelectedPluginFilterChanged(int value) => RefreshPluginView();
partial void OnSelectedPluginChanged(PluginRow? value) { OnPropertyChanged(nameof(HasSelectedPlugin)); RemovePluginCommand.NotifyCanExecuteChanged(); }
partial void OnSelectedBackupChanged(BackupItem? value) => RestoreCommand.NotifyCanExecuteChanged();
partial void OnAutoRestartAfterApplyChanged(bool value) => OnPropertyChanged(nameof(ApplyHint));
partial void OnIsDirtyChanged(bool value) { OnPropertyChanged(nameof(PluginSummary)); ApplyCommand.NotifyCanExecuteChanged(); }
partial void OnIsRunningChanged(bool value) { UpdateCommands(); OnPropertyChanged(nameof(ApplyHint)); }
partial void OnIsBusyChanged(bool value) { OnPropertyChanged(nameof(CanEdit)); UpdateCommands(); }
partial void OnIsPollingChanged(bool value) { OnPropertyChanged(nameof(CanEdit)); UpdateCommands(); }
private void UpdateCommands()
{
foreach (var command in new IRelayCommand[]
{
RefreshCommand, DetectCommand, SaveSettingsCommand, ApplyCommand, LaunchCommand, LaunchVanillaCommand,
CloseGameCommand, RestartCommand, BuildLoaderCommand, ImportDllCommand, ImportDirectoryCommand, RemovePluginCommand,
AddArgumentCommand, RemoveArgumentCommand, BrowseGameCommand, BrowseProfileCommand,
RestoreCommand, RefreshBackupsCommand, OpenGameDirectoryCommand, CancelCommand
}) command.NotifyCanExecuteChanged();
}
private void RefreshPluginView()
{
PluginsView.Refresh();
OnPropertyChanged(nameof(ShowEmptyPlugins)); OnPropertyChanged(nameof(EmptyPluginsTitle));
OnPropertyChanged(nameof(EmptyPluginsHint)); OnPropertyChanged(nameof(PluginSummary));
}
private void OnPluginChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(PluginRow.IsEnabled)) return;
IsDirty = true;
RefreshPluginView();
}
private void AddRow(PluginRow row)
{
row.PropertyChanged += OnPluginChanged;
Plugins.Add(row);
}
private void ReadSettings()
{
var settings = service.Settings;
Bin64Path = settings.Bin64Path; LauncherPath = settings.LauncherPath; ProfilePath = settings.ProfilePath;
AutoRestartAfterApply = settings.AutoRestartAfterApply;
RestartOnLocalChanges = settings.RestartOnLocalChanges;
CloseTimeoutText = settings.CloseTimeoutSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
Arguments.Clear();
foreach (string argument in settings.Arguments) Arguments.Add(new LaunchArgument { Value = argument });
}
private void WriteSettings()
{
if (!int.TryParse(CloseTimeoutText, out int seconds) || seconds is < 10 or > 600)
throw new ArgumentException("正常退出等待时间请输入 10–600 秒的整数。");
var settings = service.Settings;
settings.Bin64Path = Bin64Path.Trim(); settings.ProfilePath = ProfilePath.Trim();
settings.Arguments = Arguments.Select(a => a.Value).ToList();
settings.AutoRestartAfterApply = AutoRestartAfterApply;
settings.RestartOnLocalChanges = RestartOnLocalChanges;
settings.CloseTimeoutSeconds = seconds;
}
private async Task RefreshCoreAsync(CancellationToken token, bool replacePlugins = true, bool readSettings = true)
{
var state = replacePlugins ? await service.RefreshAsync(token) : service.ReadProcessState();
IsRunning = state.IsRunning; ProcessStatus = state.ProcessStatus; LoaderStatus = state.LoaderStatus;
if (replacePlugins)
{
string? selectedId = SelectedPlugin?.Id;
foreach (var row in Plugins) row.PropertyChanged -= OnPluginChanged;
Plugins.Clear();
foreach (var row in state.Plugins) AddRow(row);
SelectedPlugin = Plugins.FirstOrDefault(p => p.Id == selectedId);
IsDirty = false;
RefreshPluginView();
}
if (readSettings) ReadSettings();
RefreshBackups();
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task RefreshAsync() => RunAsync("刷新插件清单", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
await RefreshCoreAsync(token);
});
private static bool ConfirmDiscard() => PopupHelper.ShowConfirmDialog("当前插件清单有尚未应用的更改。重新读取将放弃这些更改。", true, "重新读取", "保留更改") == MessageBoxResult.OK;
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task DetectAsync() => RunAsync("自动检测游戏与加载器", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
service.Settings.Bin64Path = "";
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task SaveSettingsAsync() => RunAsync("保存环境设置", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanApply))]
private Task ApplyAsync() => RunAsync("应用插件配置", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.ApplyAsync(Plugins.ToArray(), AutoRestartAfterApply, token);
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanLaunch))]
private Task LaunchAsync() => LaunchCoreAsync(false);
[RelayCommand(CanExecute = nameof(CanLaunch))]
private Task LaunchVanillaAsync() => LaunchCoreAsync(true);
private Task LaunchCoreAsync(bool vanilla) => RunAsync(vanilla ? "启动原版游戏" : "启动插件游戏", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.LaunchAsync(vanilla, token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanClose))]
private Task CloseGameAsync() => RunAsync("等待游戏正常退出", async token =>
{
WriteSettings();
await service.CloseGameAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanClose))]
private Task RestartAsync() => RunAsync("正常重启游戏", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.RestartAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task BuildLoaderAsync() => RunAsync("构建或更新 XFE 加载器", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
await service.BuildLoaderAsync(token);
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private async Task ImportDllAsync()
{
var dialog = new OpenFileDialog { Title = "选择本地插件 DLL", Filter = "插件程序集 (*.dll)|*.dll", Multiselect = false };
if (dialog.ShowDialog() != true) return;
await ImportAsync(dialog.FileName, false);
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private async Task ImportDirectoryAsync()
{
var dialog = new OpenFolderDialog { Title = "选择包含已编译插件 DLL 的目录" };
if (dialog.ShowDialog() != true) return;
await ImportAsync(dialog.FolderName, true);
}
private Task ImportAsync(string source, bool development) => RunAsync("添加本地插件", async token =>
{
WriteSettings();
var row = await service.ImportLocalPluginAsync(source, development, token);
var existing = Plugins.FirstOrDefault(p => p.Id.Equals(row.Id, StringComparison.OrdinalIgnoreCase));
if (existing is null) AddRow(row); else existing.IsEnabled = true;
SelectedPlugin = existing ?? row;
IsDirty = true;
RefreshPluginView();
});
[RelayCommand(CanExecute = nameof(CanRemove))]
private void RemovePlugin()
{
if (SelectedPlugin is not { } row) return;
row.PropertyChanged -= OnPluginChanged;
Plugins.Remove(row);
SelectedPlugin = null;
IsDirty = true;
RefreshPluginView();
AppendLog($"已从待应用清单移除 {row.Name},保留插件文件。");
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void BrowseGame()
{
var dialog = new OpenFolderDialog { Title = "选择 Space Engineers 的 Bin64 文件夹", InitialDirectory = Directory.Exists(Bin64Path) ? Bin64Path : "" };
if (dialog.ShowDialog() == true) Bin64Path = dialog.FolderName;
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void BrowseProfile()
{
var dialog = new OpenFileDialog { Title = "选择 XFE 插件配置", Filter = "XFE 插件配置 (*.json)|*.json" };
if (dialog.ShowDialog() == true) ProfilePath = dialog.FileName;
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void AddArgument() => Arguments.Add(new LaunchArgument());
[RelayCommand(CanExecute = nameof(CanEdit))]
private void RemoveArgument(LaunchArgument? argument) { if (argument is not null) Arguments.Remove(argument); }
[RelayCommand(CanExecute = nameof(CanEdit))]
private void OpenGameDirectory()
{
try
{
if (!Directory.Exists(Bin64Path)) throw new DirectoryNotFoundException("请先选择有效的游戏 Bin64 文件夹。");
Process.Start(new ProcessStartInfo(Path.GetFullPath(Bin64Path)) { UseShellExecute = true });
}
catch (Exception e) { AppendLog("打开游戏目录失败:" + e.Message); }
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void RefreshBackups()
{
try
{
string? selectedPath = SelectedBackup?.Path;
Backups.Clear();
foreach (string path in service.GetBackups()) Backups.Add(new BackupItem(path));
SelectedBackup = Backups.FirstOrDefault(b => b.Path == selectedPath) ?? Backups.FirstOrDefault();
OnPropertyChanged(nameof(BackupSummary));
}
catch (Exception e) { AppendLog("读取备份失败:" + e.Message); }
}
[RelayCommand(CanExecute = nameof(CanRestore))]
private Task RestoreAsync() => RunAsync("恢复插件配置备份", async token =>
{
if (SelectedBackup is not { } backup) return;
if (PopupHelper.ShowConfirmDialog($"将插件清单与加载版本恢复为:\n{backup.Name}\n\n当前配置会先备份。" + (IsDirty ? "未应用的清单更改将被放弃。" : ""), true, "恢复备份", "取消") != MessageBoxResult.OK) return;
WriteSettings();
await service.RestoreAsync(backup.Path, AutoRestartAfterApply, token);
await RefreshCoreAsync(token);
});
[RelayCommand]
private void ExportLog()
{
var dialog = new SaveFileDialog { Title = "导出运行日志", Filter = "文本文件 (*.txt)|*.txt", FileName = $"SE插件管理日志-{DateTime.Now:yyyyMMdd-HHmmss}.txt" };
if (dialog.ShowDialog() != true) return;
try { File.WriteAllText(dialog.FileName, LogText, new System.Text.UTF8Encoding(false)); AppendLog("日志已导出:" + dialog.FileName); }
catch (Exception e) { AppendLog("导出日志失败:" + e.Message); }
}
[RelayCommand]
private void ClearLog() { logLines.Clear(); LogText = ""; }
[RelayCommand(CanExecute = nameof(IsBusy))]
private void Cancel() => operationCancellation?.Cancel();
private async Task RunAsync(string title, Func<CancellationToken, Task> action)
{
if (!CanEdit) return;
IsBusy = true;
using var cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
operationCancellation = cancel;
bool entered = false;
try
{
await operationGate.WaitAsync(cancel.Token); entered = true;
AppendLog(title + "…");
await action(cancel.Token);
AppendLog(title + "完成。");
}
catch (OperationCanceledException) { if (!shuttingDown) AppendLog(title + "已取消。已完成的步骤保留,请刷新确认当前状态。"); }
catch (Exception e) { if (!shuttingDown) AppendLog(title + "失败:" + e.Message); }
finally
{
if (entered) operationGate.Release();
operationCancellation = null;
IsBusy = false;
}
}
private async Task PollLoopAsync()
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
try
{
while (await timer.WaitForNextTickAsync(lifetime.Token))
await PollOnceAsync(lifetime.Token);
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
}
internal async Task PollOnceAsync(CancellationToken token = default)
{
if (IsBusy || shuttingDown || !await operationGate.WaitAsync(0, token)) return;
bool monitoringWasEnabled = service.Settings.RestartOnLocalChanges;
IsPolling = true;
try
{
bool synchronized = !IsDirty && await service.PollAsync(token);
if (synchronized)
{
// Our synchronization committed new managed DLL paths. Refresh those paths
// before a later Apply can write the old versions back into the profile.
await RefreshCoreAsync(token, readSettings: false);
}
else
{
// Ordinary polling must not replace the optimistic-edit snapshot.
var state = service.ReadProcessState();
IsRunning = state.IsRunning; ProcessStatus = state.ProcessStatus; LoaderStatus = state.LoaderStatus;
}
lastPollError = "";
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
catch (Exception e)
{
if (e.Message != lastPollError) { AppendLog("后台监视:" + e.Message); lastPollError = e.Message; }
}
finally
{
if (monitoringWasEnabled && !service.Settings.RestartOnLocalChanges) RestartOnLocalChanges = false;
IsPolling = false;
operationGate.Release();
}
}
private void OnProgress(string message)
{
if (shuttingDown) return;
if (dispatcher.CheckAccess()) AppendLog(message);
else dispatcher.BeginInvoke(new Action(() => { if (!shuttingDown) AppendLog(message); }));
}
private void AppendLog(string message)
{
Status = message;
logLines.Enqueue($"[{DateTime.Now:HH:mm:ss}] {message}");
while (logLines.Count > 400) logLines.Dequeue();
LogText = string.Join(Environment.NewLine, logLines);
}
public async Task ShutdownAsync()
{
if (shuttingDown) return;
shuttingDown = true;
service.Progress -= OnProgress;
await lifetime.CancelAsync();
operationCancellation?.Cancel();
if (pollingTask is not null) await pollingTask;
await operationGate.WaitAsync();
try { service.Dispose(); }
finally { operationGate.Release(); }
foreach (var row in Plugins) row.PropertyChanged -= OnPluginChanged;
lifetime.Dispose();
}
}
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Data;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using XFEToolBox.Client.Utilities;
namespace XFEToolBox.Tools.SpaceEngineers.ViewModels;
public sealed partial class LaunchArgument : ObservableObject
{
[ObservableProperty] private string value = "";
}
public sealed record BackupItem(string Path)
{
public string Name => System.IO.Path.GetFileName(Path);
}
public sealed partial class MainPageViewModel : ObservableObject
{
private readonly ManagerService service;
private readonly Dispatcher dispatcher;
private readonly bool pollingEnabled;
private readonly SemaphoreSlim operationGate = new(1, 1);
private readonly CancellationTokenSource lifetime = new();
private readonly Queue<string> logLines = new();
private CancellationTokenSource? operationCancellation;
private Task? pollingTask;
private bool initialized, shuttingDown;
private string lastPollError = "";
public ObservableCollection<PluginRow> Plugins { get; } = [];
public ObservableCollection<LaunchArgument> Arguments { get; } = [];
public ObservableCollection<BackupItem> Backups { get; } = [];
public ICollectionView PluginsView { get; }
public string[] PluginFilters { get; } = ["全部插件", "已启用", "已停用"];
[ObservableProperty] private int selectedTab;
[ObservableProperty] private int selectedPluginFilter;
[ObservableProperty] private string filter = "";
[ObservableProperty] private string bin64Path = "";
[ObservableProperty] private string launcherPath = "";
[ObservableProperty] private string profilePath = "";
[ObservableProperty] private string closeTimeoutText = "120";
[ObservableProperty] private bool autoRestartAfterApply;
[ObservableProperty] private bool restartOnLocalChanges;
[ObservableProperty] private bool isBusy;
[ObservableProperty] private bool isPolling;
[ObservableProperty] private bool isRunning;
[ObservableProperty] private bool isDirty;
[ObservableProperty] private PluginRow? selectedPlugin;
[ObservableProperty] private BackupItem? selectedBackup;
[ObservableProperty] private string processStatus = "正在读取游戏状态…";
[ObservableProperty] private string loaderStatus = "请配置游戏与插件加载器。";
[ObservableProperty] private string status = "准备读取插件配置。";
[ObservableProperty] private string logText = "";
public bool CanEdit => !IsBusy && !IsPolling && !shuttingDown;
public bool CanLaunch => CanEdit && !IsRunning;
public bool CanClose => CanEdit && IsRunning;
public bool CanApply => CanEdit && IsDirty;
public bool CanRemove => CanEdit && SelectedPlugin is not null;
public bool CanRestore => CanEdit && SelectedBackup is not null;
public bool HasSelectedPlugin => SelectedPlugin is not null;
public bool ShowEmptyPlugins => PluginsView.IsEmpty;
public string EmptyPluginsTitle => Plugins.Count == 0 ? "还没有插件" : "没有匹配的插件";
public string EmptyPluginsHint => Plugins.Count == 0 ? "添加本地插件 DLL 或包含已编译插件的目录,然后应用更改。首次使用请在「启动与环境」构建 XFE 加载器。" : "试试其他关键词,或切换到「全部插件」。";
public string PluginSummary => $"{Plugins.Count} 个插件 · {Plugins.Count(p => p.IsEnabled)} 个启用" + (IsDirty ? " · 有待应用更改" : "");
public string ApplyHint => AutoRestartAfterApply ? "应用后自动正常重启运行中的游戏;请先保存存档。" : IsRunning ? "游戏运行中。请先正常退出,或在「启动与环境」启用应用后自动重启。" : "插件更改在下次启动游戏时生效。";
public string BackupSummary => Backups.Count == 0 ? "当前配置还没有备份。首次应用已有配置时会自动创建。" : $"发现 {Backups.Count} 份配置备份。恢复后是否重启遵循自动重启设置。";
public MainPageViewModel() : this(new ManagerService()) { }
public MainPageViewModel(ManagerService service, bool pollingEnabled = true)
{
this.service = service;
this.pollingEnabled = pollingEnabled;
dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
PluginsView = CollectionViewSource.GetDefaultView(Plugins);
PluginsView.Filter = MatchPlugin;
service.Progress += OnProgress;
ReadSettings();
}
public async Task InitializeAsync()
{
if (initialized || shuttingDown) return;
initialized = true;
await RunAsync("读取游戏与插件配置", async token => await RefreshCoreAsync(token));
if (pollingEnabled && !shuttingDown) pollingTask = PollLoopAsync();
}
private bool MatchPlugin(object item)
{
if (item is not PluginRow plugin) return false;
bool category = SelectedPluginFilter switch
{
1 => plugin.IsEnabled,
2 => !plugin.IsEnabled,
_ => true
};
return category && (string.IsNullOrWhiteSpace(Filter) || Contains(plugin.Name, Filter) || Contains(plugin.Id, Filter) || Contains(plugin.Location, Filter) || Contains(plugin.SourcePath, Filter));
}
private static bool Contains(string? text, string search) => text?.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase) == true;
partial void OnFilterChanged(string value) => RefreshPluginView();
partial void OnSelectedPluginFilterChanged(int value) => RefreshPluginView();
partial void OnSelectedPluginChanged(PluginRow? value) { OnPropertyChanged(nameof(HasSelectedPlugin)); RemovePluginCommand.NotifyCanExecuteChanged(); }
partial void OnSelectedBackupChanged(BackupItem? value) => RestoreCommand.NotifyCanExecuteChanged();
partial void OnAutoRestartAfterApplyChanged(bool value) => OnPropertyChanged(nameof(ApplyHint));
partial void OnIsDirtyChanged(bool value) { OnPropertyChanged(nameof(PluginSummary)); ApplyCommand.NotifyCanExecuteChanged(); }
partial void OnIsRunningChanged(bool value) { UpdateCommands(); OnPropertyChanged(nameof(ApplyHint)); }
partial void OnIsBusyChanged(bool value) { OnPropertyChanged(nameof(CanEdit)); UpdateCommands(); }
partial void OnIsPollingChanged(bool value) { OnPropertyChanged(nameof(CanEdit)); UpdateCommands(); }
private void UpdateCommands()
{
foreach (var command in new IRelayCommand[]
{
RefreshCommand, DetectCommand, SaveSettingsCommand, ApplyCommand, LaunchCommand, LaunchVanillaCommand,
CloseGameCommand, RestartCommand, BuildLoaderCommand, ImportDllCommand, ImportDirectoryCommand, RemovePluginCommand,
AddArgumentCommand, RemoveArgumentCommand, BrowseGameCommand, BrowseProfileCommand,
RestoreCommand, RefreshBackupsCommand, OpenGameDirectoryCommand, CancelCommand
}) command.NotifyCanExecuteChanged();
}
private void RefreshPluginView()
{
PluginsView.Refresh();
OnPropertyChanged(nameof(ShowEmptyPlugins)); OnPropertyChanged(nameof(EmptyPluginsTitle));
OnPropertyChanged(nameof(EmptyPluginsHint)); OnPropertyChanged(nameof(PluginSummary));
}
private void OnPluginChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(PluginRow.IsEnabled)) return;
IsDirty = true;
RefreshPluginView();
}
private void AddRow(PluginRow row)
{
row.PropertyChanged += OnPluginChanged;
Plugins.Add(row);
}
private void ReadSettings()
{
var settings = service.Settings;
Bin64Path = settings.Bin64Path; LauncherPath = settings.LauncherPath; ProfilePath = settings.ProfilePath;
AutoRestartAfterApply = settings.AutoRestartAfterApply;
RestartOnLocalChanges = settings.RestartOnLocalChanges;
CloseTimeoutText = settings.CloseTimeoutSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
Arguments.Clear();
foreach (string argument in settings.Arguments) Arguments.Add(new LaunchArgument { Value = argument });
}
private void WriteSettings()
{
if (!int.TryParse(CloseTimeoutText, out int seconds) || seconds is < 10 or > 600)
throw new ArgumentException("正常退出等待时间请输入 10–600 秒的整数。");
var settings = service.Settings;
settings.Bin64Path = Bin64Path.Trim(); settings.ProfilePath = ProfilePath.Trim();
settings.Arguments = Arguments.Select(a => a.Value).ToList();
settings.AutoRestartAfterApply = AutoRestartAfterApply;
settings.RestartOnLocalChanges = RestartOnLocalChanges;
settings.CloseTimeoutSeconds = seconds;
}
private async Task RefreshCoreAsync(CancellationToken token, bool replacePlugins = true, bool readSettings = true)
{
var state = replacePlugins ? await service.RefreshAsync(token) : service.ReadProcessState();
IsRunning = state.IsRunning; ProcessStatus = state.ProcessStatus; LoaderStatus = state.LoaderStatus;
if (replacePlugins)
{
string? selectedId = SelectedPlugin?.Id;
foreach (var row in Plugins) row.PropertyChanged -= OnPluginChanged;
Plugins.Clear();
foreach (var row in state.Plugins) AddRow(row);
SelectedPlugin = Plugins.FirstOrDefault(p => p.Id == selectedId);
IsDirty = false;
RefreshPluginView();
}
if (readSettings) ReadSettings();
RefreshBackups();
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task RefreshAsync() => RunAsync("刷新插件清单", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
await RefreshCoreAsync(token);
});
private static bool ConfirmDiscard() => PopupHelper.ShowConfirmDialog("当前插件清单有尚未应用的更改。重新读取将放弃这些更改。", true, "重新读取", "保留更改") == MessageBoxResult.OK;
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task DetectAsync() => RunAsync("自动检测游戏与加载器", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
service.Settings.Bin64Path = "";
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task SaveSettingsAsync() => RunAsync("保存环境设置", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanApply))]
private Task ApplyAsync() => RunAsync("应用插件配置", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.ApplyAsync(Plugins.ToArray(), AutoRestartAfterApply, token);
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanLaunch))]
private Task LaunchAsync() => LaunchCoreAsync(false);
[RelayCommand(CanExecute = nameof(CanLaunch))]
private Task LaunchVanillaAsync() => LaunchCoreAsync(true);
private Task LaunchCoreAsync(bool vanilla) => RunAsync(vanilla ? "启动原版游戏" : "启动插件游戏", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.LaunchAsync(vanilla, token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanClose))]
private Task CloseGameAsync() => RunAsync("等待游戏正常退出", async token =>
{
WriteSettings();
await service.CloseGameAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanClose))]
private Task RestartAsync() => RunAsync("正常重启游戏", async token =>
{
WriteSettings();
await service.SaveSettingsAsync(token);
await service.RestartAsync(token);
await RefreshCoreAsync(token, replacePlugins: !IsDirty);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private Task BuildLoaderAsync() => RunAsync("构建或更新 XFE 加载器", async token =>
{
if (IsDirty && !ConfirmDiscard()) return;
WriteSettings();
await service.BuildLoaderAsync(token);
await RefreshCoreAsync(token);
});
[RelayCommand(CanExecute = nameof(CanEdit))]
private async Task ImportDllAsync()
{
var dialog = new OpenFileDialog { Title = "选择本地插件 DLL", Filter = "插件程序集 (*.dll)|*.dll", Multiselect = false };
if (dialog.ShowDialog() != true) return;
await ImportAsync(dialog.FileName, false);
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private async Task ImportDirectoryAsync()
{
var dialog = new OpenFolderDialog { Title = "选择包含已编译插件 DLL 的目录" };
if (dialog.ShowDialog() != true) return;
await ImportAsync(dialog.FolderName, true);
}
private Task ImportAsync(string source, bool development) => RunAsync("添加本地插件", async token =>
{
WriteSettings();
var row = await service.ImportLocalPluginAsync(source, development, token);
var existing = Plugins.FirstOrDefault(p => p.Id.Equals(row.Id, StringComparison.OrdinalIgnoreCase));
if (existing is null) AddRow(row); else existing.IsEnabled = true;
SelectedPlugin = existing ?? row;
IsDirty = true;
RefreshPluginView();
});
[RelayCommand(CanExecute = nameof(CanRemove))]
private void RemovePlugin()
{
if (SelectedPlugin is not { } row) return;
row.PropertyChanged -= OnPluginChanged;
Plugins.Remove(row);
SelectedPlugin = null;
IsDirty = true;
RefreshPluginView();
AppendLog($"已从待应用清单移除 {row.Name},保留插件文件。");
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void BrowseGame()
{
var dialog = new OpenFolderDialog { Title = "选择 Space Engineers 的 Bin64 文件夹", InitialDirectory = Directory.Exists(Bin64Path) ? Bin64Path : "" };
if (dialog.ShowDialog() == true) Bin64Path = dialog.FolderName;
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void BrowseProfile()
{
var dialog = new OpenFileDialog { Title = "选择 XFE 插件配置", Filter = "XFE 插件配置 (*.json)|*.json" };
if (dialog.ShowDialog() == true) ProfilePath = dialog.FileName;
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void AddArgument() => Arguments.Add(new LaunchArgument());
[RelayCommand(CanExecute = nameof(CanEdit))]
private void RemoveArgument(LaunchArgument? argument) { if (argument is not null) Arguments.Remove(argument); }
[RelayCommand(CanExecute = nameof(CanEdit))]
private void OpenGameDirectory()
{
try
{
if (!Directory.Exists(Bin64Path)) throw new DirectoryNotFoundException("请先选择有效的游戏 Bin64 文件夹。");
Process.Start(new ProcessStartInfo(Path.GetFullPath(Bin64Path)) { UseShellExecute = true });
}
catch (Exception e) { AppendLog("打开游戏目录失败:" + e.Message); }
}
[RelayCommand(CanExecute = nameof(CanEdit))]
private void RefreshBackups()
{
try
{
string? selectedPath = SelectedBackup?.Path;
Backups.Clear();
foreach (string path in service.GetBackups()) Backups.Add(new BackupItem(path));
SelectedBackup = Backups.FirstOrDefault(b => b.Path == selectedPath) ?? Backups.FirstOrDefault();
OnPropertyChanged(nameof(BackupSummary));
}
catch (Exception e) { AppendLog("读取备份失败:" + e.Message); }
}
[RelayCommand(CanExecute = nameof(CanRestore))]
private Task RestoreAsync() => RunAsync("恢复插件配置备份", async token =>
{
if (SelectedBackup is not { } backup) return;
if (PopupHelper.ShowConfirmDialog($"将插件清单与加载版本恢复为:\n{backup.Name}\n\n当前配置会先备份。" + (IsDirty ? "未应用的清单更改将被放弃。" : ""), true, "恢复备份", "取消") != MessageBoxResult.OK) return;
WriteSettings();
await service.RestoreAsync(backup.Path, AutoRestartAfterApply, token);
await RefreshCoreAsync(token);
});
[RelayCommand]
private void ExportLog()
{
var dialog = new SaveFileDialog { Title = "导出运行日志", Filter = "文本文件 (*.txt)|*.txt", FileName = $"SE插件管理日志-{DateTime.Now:yyyyMMdd-HHmmss}.txt" };
if (dialog.ShowDialog() != true) return;
try { File.WriteAllText(dialog.FileName, LogText, new System.Text.UTF8Encoding(false)); AppendLog("日志已导出:" + dialog.FileName); }
catch (Exception e) { AppendLog("导出日志失败:" + e.Message); }
}
[RelayCommand]
private void ClearLog() { logLines.Clear(); LogText = ""; }
[RelayCommand(CanExecute = nameof(IsBusy))]
private void Cancel() => operationCancellation?.Cancel();
private async Task RunAsync(string title, Func<CancellationToken, Task> action)
{
if (!CanEdit) return;
IsBusy = true;
using var cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
operationCancellation = cancel;
bool entered = false;
try
{
await operationGate.WaitAsync(cancel.Token); entered = true;
AppendLog(title + "…");
await action(cancel.Token);
AppendLog(title + "完成。");
}
catch (OperationCanceledException) { if (!shuttingDown) AppendLog(title + "已取消。已完成的步骤保留,请刷新确认当前状态。"); }
catch (Exception e) { if (!shuttingDown) AppendLog(title + "失败:" + e.Message); }
finally
{
if (entered) operationGate.Release();
operationCancellation = null;
IsBusy = false;
}
}
private async Task PollLoopAsync()
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
try
{
while (await timer.WaitForNextTickAsync(lifetime.Token))
await PollOnceAsync(lifetime.Token);
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
}
internal async Task PollOnceAsync(CancellationToken token = default)
{
if (IsBusy || shuttingDown || !await operationGate.WaitAsync(0, token)) return;
bool monitoringWasEnabled = service.Settings.RestartOnLocalChanges;
IsPolling = true;
try
{
bool synchronized = !IsDirty && await service.PollAsync(token);
if (synchronized)
{
// Our synchronization committed new managed DLL paths. Refresh those paths
// before a later Apply can write the old versions back into the profile.
await RefreshCoreAsync(token, readSettings: false);
}
else
{
// Ordinary polling must not replace the optimistic-edit snapshot.
var state = service.ReadProcessState();
IsRunning = state.IsRunning; ProcessStatus = state.ProcessStatus; LoaderStatus = state.LoaderStatus;
}
lastPollError = "";
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
catch (Exception e)
{
if (e.Message != lastPollError) { AppendLog("后台监视:" + e.Message); lastPollError = e.Message; }
}
finally
{
if (monitoringWasEnabled && !service.Settings.RestartOnLocalChanges) RestartOnLocalChanges = false;
IsPolling = false;
operationGate.Release();
}
}
private void OnProgress(string message)
{
if (shuttingDown) return;
if (dispatcher.CheckAccess()) AppendLog(message);
else dispatcher.BeginInvoke(new Action(() => { if (!shuttingDown) AppendLog(message); }));
}
private void AppendLog(string message)
{
Status = message;
logLines.Enqueue($"[{DateTime.Now:HH:mm:ss}] {message}");
while (logLines.Count > 400) logLines.Dequeue();
LogText = string.Join(Environment.NewLine, logLines);
}
public async Task ShutdownAsync()
{
if (shuttingDown) return;
shuttingDown = true;
service.Progress -= OnProgress;
await lifetime.CancelAsync();
operationCancellation?.Cancel();
if (pollingTask is not null) await pollingTask;
await operationGate.WaitAsync();
try { service.Dispose(); }
finally { operationGate.Release(); }
foreach (var row in Plugins) row.PropertyChanged -= OnPluginChanged;
lifetime.Dispose();
}
}