XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEEDToolBox

feat: add desktop game launch and refine upgrade flyout (0.4.14)

1128292
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

13 个文件 +156 -39
Modified README.md +2 -2
@@ -1,12 +1,12 @@
1 1 # XFE ED ToolBox
2 2
3 当前 0.4.13 完成状态确认、绕星三轴姿态、实验效果和清空清单修复的离线验证;按用户要求暂不进行本版游戏实测。上一版 0.4.12 的 HR 3230 实飞记录保留,不能代替本版验收。范围与记录见 [导航验收](docs/NAVIGATION.md)。
3 当前 0.4.14 在主页增加非 VR 模式的“启动游戏”,游戏已运行时不会重复启动;全局清单 Flyout 增加圆角及滚动条内侧留白。自动导航沿用 0.4.13 的状态确认与绕星三轴姿态修复,按用户要求暂不进行本版游戏实测。上一版 0.4.12 的 HR 3230 实飞记录保留,不能代替本版验收。范围与记录见 [导航验收](docs/NAVIGATION.md)。
4 4
5 5 Elite Dangerous 的 WinUI 3 指挥官工具箱。当前版本提供实时数据、公共行情、游戏原生航路与自动导航、银河星图、材料仓库、工程声望与材料计算、飞船编辑和 EDSY 链接交换,以及进程重启和可选全局快捷键。日志读取与飞船编辑在游戏外运行;原生导航需要在设置中安装可卸载的游戏插件。自动贸易与原生重进仍暂缓。
6 6
7 7 ## 运行
8 8
9 双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `publish/XFEEDToolBox-0.4.13/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可长按星点选择,再依次点击“规划航线”和“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
9 双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `publish/XFEEDToolBox-0.4.14/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可长按星点选择,再依次点击“规划航线”和“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
10 10
11 11 ```powershell
12 12 pwsh -File scripts/Build.ps1
Modified Start-Toolbox.cmd +4 -0
@@ -1,6 +1,10 @@
1 1 @echo off
2 2 setlocal
3 3 cd /d "%~dp0"
4 if exist "publish\XFEEDToolBox-0.4.14\XFEEDToolBox.exe" (
5 start "" "publish\XFEEDToolBox-0.4.14\XFEEDToolBox.exe" %*
6 exit /b
7 )
4 8 if exist "publish\XFEEDToolBox-0.4.13\XFEEDToolBox.exe" (
5 9 start "" "publish\XFEEDToolBox-0.4.13\XFEEDToolBox.exe" %*
6 10 exit /b
Modified XFEEDToolBox.Core/Session/GameSessionController.cs +27 -15
@@ -1,13 +1,15 @@
1 1 using System.Diagnostics;
2 2 namespace XFEEDToolBox.Core.Session;
3 3
4 public enum SessionOperation { Relog, Restart }
4 public enum SessionOperation { Relog, Restart, Start }
5 5 public sealed record GameSessionTarget(int ProcessId, long StartedUtcTicks, string Executable, string Launcher, bool Steam);
6 6 public sealed record SessionProgress(bool Active, string Message);
7 7 public interface IGameSessionDriver
8 8 {
9 9 bool CanRelog { get; }
10 10 Task<GameSessionTarget> FindTargetAsync(CancellationToken token);
11 Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken token);
12 Task ValidateLaunchAsync(GameSessionTarget target, CancellationToken token);
11 13 Task ValidateRestartAsync(GameSessionTarget target, CancellationToken token);
12 14 Task TerminateAsync(GameSessionTarget target, CancellationToken token);
13 15 Task<bool> HasExitedAsync(GameSessionTarget target, CancellationToken token);
@@ -16,7 +18,7 @@ public interface IGameSessionDriver
16 18 Task RelogAsync(GameSessionTarget target, CancellationToken token);
17 19 }
18 20
19 /// <summary>User-triggered restart terminates only the pinned game process. No window input.</summary>
21 /// <summary>Explicit session actions share one operation gate. Starting never terminates a process.</summary>
20 22 public sealed class GameSessionController(IGameSessionDriver driver, Func<bool> routeActive)
21 23 {
22 24 private int active;
@@ -36,33 +38,43 @@ public sealed class GameSessionController(IGameSessionDriver driver, Func<bool>
36 38 token.ThrowIfCancellationRequested();
37 39 if (routeActive()) throw new InvalidOperationException("请先停止工具箱路线,再进行会话操作。");
38 40 if (operation == SessionOperation.Relog && !CanRelog) throw new NotSupportedException("当前构建的底层重进入口仍在验证,尚未启用。");
39 var target = await driver.FindTargetAsync(token);
41 var starting = operation == SessionOperation.Start;
42 var target = starting ? await driver.FindLaunchTargetAsync(token) : await driver.FindTargetAsync(token);
40 43 if(expected is not null&&target!=expected)throw new InvalidOperationException("游戏进程已变化,已取消自动重启。");
41 44 if (operation == SessionOperation.Relog) { await driver.RelogAsync(target, token); Report("底层重进完成"); return; }
42 await driver.ValidateRestartAsync(target, token);
43 token.ThrowIfCancellationRequested();
44 Report($"正在直接结束游戏进程 PID {target.ProcessId}");
45 await driver.TerminateAsync(target, token);
46 var watch = Stopwatch.StartNew();
47 while (!await driver.HasExitedAsync(target, token))
45 if (starting)
48 46 {
49 if (watch.Elapsed >= ExitTimeout) throw new TimeoutException("未确认原游戏进程结束,已停止重启。");
50 await Task.Delay(PollInterval, token);
47 if (target.ProcessId > 0) { Report($"游戏已运行 · PID {target.ProcessId},无需重复启动。"); return; }
48 await driver.ValidateLaunchAsync(target, token);
49 Report("正在通过官方启动器启动游戏 · 非 VR 模式");
50 }
51 else
52 {
53 await driver.ValidateRestartAsync(target, token);
54 token.ThrowIfCancellationRequested();
55 Report($"正在直接结束游戏进程 PID {target.ProcessId}");
56 await driver.TerminateAsync(target, token);
57 var exitWatch = Stopwatch.StartNew();
58 while (!await driver.HasExitedAsync(target, token))
59 {
60 if (exitWatch.Elapsed >= ExitTimeout) throw new TimeoutException("未确认原游戏进程结束,已停止重启。");
61 await Task.Delay(PollInterval, token);
62 }
63 Report("原游戏进程已结束,正在通过官方启动器重新启动");
51 64 }
52 65 token.ThrowIfCancellationRequested();
53 Report("原游戏进程已结束,正在通过官方启动器重新启动");
54 66 await driver.LaunchAsync(target, token);
55 watch.Restart();
67 var watch = Stopwatch.StartNew();
56 68 while (watch.Elapsed < StartTimeout)
57 69 {
58 70 token.ThrowIfCancellationRequested();
59 71 if (await driver.FindReplacementAsync(target, token) is { } p && p.StartedUtcTicks > target.StartedUtcTicks && string.Equals(p.Executable, target.Executable, StringComparison.OrdinalIgnoreCase))
60 { Report($"游戏已重新启动 · PID {p.ProcessId}"); return; }
72 { Report($"游戏已{(starting ? "启动" : "重新启动")} · PID {p.ProcessId}"); return; }
61 73 await Task.Delay(PollInterval, token);
62 74 }
63 75 throw new TimeoutException("启动请求已发出,但未确认到同一安装目录的新游戏进程。请查看官方启动器的登录或更新提示。");
64 76 }
65 catch (OperationCanceledException) { Report("已取消后续操作;已经结束的游戏进程无法撤销。"); throw; }
77 catch (OperationCanceledException) { Report(operation == SessionOperation.Start ? "已取消启动等待;已经交给官方启动器的请求不会强制撤回。" : "已取消后续操作;已经结束的游戏进程无法撤销。"); throw; }
66 78 catch (Exception e) { Report("会话操作已停止:" + e.Message); throw; }
67 79 finally { Interlocked.Exchange(ref active, 0); Progress?.Invoke(new(false, "")); }
68 80 }
Modified XFEEDToolBox.Test/SessionTests.cs +49 -3
@@ -20,14 +20,16 @@ public class SessionTests
20 20 public bool CanRelog => false;
21 21 public GameSessionTarget Target = new(1, 100, "C:\\game\\EliteDangerous64.exe", "C:\\game\\EDLaunch.exe", true);
22 22 public readonly List<string> Calls = [];
23 public bool Exited, NoExit, WrongReplacement, MissingLauncher;
23 public bool Exited, NoExit, WrongReplacement, MissingLauncher, GameAlreadyRunning;
24 24 public CancellationTokenSource? CancelOnKill;
25 25 public TaskCompletionSource? HoldValidate;
26 26 public Task<GameSessionTarget> FindTargetAsync(CancellationToken t) { Calls.Add("find"); return Task.FromResult(Target); }
27 public Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken t) { Calls.Add("find-launch"); return Task.FromResult(GameAlreadyRunning ? Target : Target with { ProcessId=0,StartedUtcTicks=0 }); }
28 public async Task ValidateLaunchAsync(GameSessionTarget p,CancellationToken t) { Calls.Add("validate-launch"); if(MissingLauncher)throw new InvalidOperationException("missing"); if(HoldValidate!=null)await HoldValidate.Task.WaitAsync(t); }
27 29 public async Task ValidateRestartAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("validate"); if (MissingLauncher) throw new InvalidOperationException("missing"); if (HoldValidate != null) await HoldValidate.Task.WaitAsync(t); }
28 30 public Task TerminateAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("kill"); Exited = !NoExit; CancelOnKill?.Cancel(); return Task.CompletedTask; }
29 31 public Task<bool> HasExitedAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult(Exited);
30 public Task LaunchAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("launch"); Check.That(Exited); return Task.CompletedTask; }
32 public Task LaunchAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("launch"); Check.That(p.ProcessId==0||Exited); return Task.CompletedTask; }
31 33 public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult<GameSessionTarget?>(p with { ProcessId = 2, StartedUtcTicks = 200, Executable = WrongReplacement ? "C:\\other\\EliteDangerous64.exe" : p.Executable });
32 34 public Task RelogAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("relog"); return Task.CompletedTask; }
33 35 }
@@ -69,7 +71,51 @@ public class SessionTests
69 71 [Test] public async Task UnverifiedNativeRelogAndActiveRouteInvokeNothing()
70 72 {
71 73 var d = new Fake(); await Fails<NotSupportedException>(() => Controller(d).RunAsync(SessionOperation.Relog));
72 await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Restart)); Check.Equal(0, d.Calls.Count);
74 await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Restart));
75 await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Start)); Check.Equal(0, d.Calls.Count);
76 }
77 [Test] public async Task StartLaunchesStoppedGameWithoutTermination()
78 {
79 var d=new Fake();var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
80 await controller.RunAsync(SessionOperation.Start);
81 Check.Equal("find-launch,validate-launch,launch",string.Join(',',d.Calls));
82 Check.That(!controller.Active&&messages.Any(m=>m.Contains("游戏已启动",StringComparison.Ordinal)));
83 }
84 [Test] public async Task StartRecognizesExistingGameWithoutReopeningLauncher()
85 {
86 var d=new Fake{GameAlreadyRunning=true};var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
87 await controller.RunAsync(SessionOperation.Start);
88 Check.Equal("find-launch",string.Join(',',d.Calls));Check.That(messages.Any(m=>m.Contains("无需重复启动",StringComparison.Ordinal))&&!controller.Active);
89 }
90 [Test] public async Task StartWithoutLauncherDoesNotKillOrLaunchAnything()
91 {
92 var d=new Fake{MissingLauncher=true};var controller=Controller(d);
93 await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));
94 Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
95 }
96 [Test] public async Task StartCannotAcceptAnotherInstallationAsSuccessful()
97 {
98 var d=new Fake{WrongReplacement=true};var controller=Controller(d);
99 await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
100 }
101 [Test] public async Task CancelledStartNeverSubmitsLaunchAfterValidation()
102 {
103 using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
104 var operation=controller.RunAsync(SessionOperation.Start,stop.Token);stop.Cancel();
105 await Fails<OperationCanceledException>(()=>operation);Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
106 }
107 [Test] public async Task RepeatedStartRequestSharesTheSessionOperationGate()
108 {
109 using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
110 var first=controller.RunAsync(SessionOperation.Start,stop.Token);
111 await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));stop.Cancel();
112 await Fails<OperationCanceledException>(()=>first);Check.Equal(1,d.Calls.Count(c=>c=="find-launch"));Check.That(!d.Calls.Contains("kill"));
113 }
114 [Test] public async Task StartCanBeRetriedAfterAnUnconfirmedLaunch()
115 {
116 var d=new Fake{WrongReplacement=true};var controller=Controller(d);
117 await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));d.WrongReplacement=false;
118 await controller.RunAsync(SessionOperation.Start);Check.Equal(2,d.Calls.Count(c=>c=="launch"));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
73 119 }
74 120 }
75 121
Modified XFEEDToolBox/Services/WindowsGameSessionDriver.cs +49 -4
@@ -5,7 +5,7 @@ using XFEEDToolBox.Core.Native;
5 5 using XFEEDToolBox.Core.Session;
6 6 namespace XFEEDToolBox.Services;
7 7
8 public sealed class WindowsGameSessionDriver(Func<PlayerState> player) : IGameSessionDriver
8 public sealed class WindowsGameSessionDriver(Func<PlayerState> player, Func<string>? configuredExecutable = null) : IGameSessionDriver
9 9 {
10 10 public bool CanRelog => false;
11 11 public async Task<GameSessionTarget> FindTargetAsync(CancellationToken token)
@@ -13,11 +13,36 @@ public sealed class WindowsGameSessionDriver(Func<PlayerState> player) : IGameSe
13 13 var environment = await GameEnvironmentReader.ReadAsync(player(), token: token);
14 14 if (environment.ProcessId is not { } pid || environment.ProfileMode != "standard") throw new InvalidOperationException("没有唯一识别到标准档案的游戏实例。");
15 15 using var process = Process.GetProcessById(pid);
16 var root = Directory.GetParent(environment.Executable)?.Parent?.Parent?.FullName ?? "";
16 return await InstallationAsync(environment.Executable,pid,process.StartTime.ToUniversalTime().Ticks,token);
17 }
18 public async Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken token)
19 {
20 token.ThrowIfCancellationRequested();
21 var executable = configuredExecutable?.Invoke().Trim() ?? "";
22 var games = Process.GetProcessesByName("EliteDangerous64");
23 try
24 {
25 if (games.Length > 1) throw new InvalidOperationException("检测到多个游戏实例,请先确认当前游戏状态。");
26 if (games.Length == 1)
27 {
28 var game = games[0];
29 var runningPath = game.MainModule?.FileName ?? throw new InvalidOperationException("无法核对正在运行的游戏路径。");
30 if (executable.Length > 0 && !string.Equals(Path.GetFullPath(executable),runningPath,StringComparison.OrdinalIgnoreCase))
31 throw new InvalidOperationException("游戏已在另一个安装目录运行,未启动第二个实例。");
32 return await InstallationAsync(runningPath,game.Id,game.StartTime.ToUniversalTime().Ticks,token);
33 }
34 }
35 finally { foreach (var game in games) game.Dispose(); }
36 if (executable.Length == 0) throw new InvalidOperationException("未找到游戏目录,请先在“设置 → 游戏插件”中选择 EliteDangerous64.exe。");
37 return await InstallationAsync(Path.GetFullPath(executable),0,0,token);
38 }
39 private static async Task<GameSessionTarget> InstallationAsync(string executable,int processId,long startedUtcTicks,CancellationToken token)
40 {
41 var root = Directory.GetParent(executable)?.Parent?.Parent?.FullName ?? "";
17 42 var launcher = Path.Combine(root, "EDLaunch.exe");
18 43 var manifest = Directory.GetParent(root)?.Parent is { Name: "steamapps" } apps ? Path.Combine(apps.FullName, "appmanifest_359320.acf") : "";
19 44 var steam = manifest.Length > 0 && File.Exists(manifest) && (await File.ReadAllTextAsync(manifest, token)).Contains("\"359320\"", StringComparison.Ordinal);
20 return new(pid, process.StartTime.ToUniversalTime().Ticks, environment.Executable, File.Exists(launcher) ? launcher : "", steam);
45 return new(processId, startedUtcTicks, executable, File.Exists(launcher) ? launcher : "", steam);
21 46 }
22 47 private static Process OpenVerified(GameSessionTarget target)
23 48 {
@@ -32,7 +57,16 @@ public sealed class WindowsGameSessionDriver(Func<PlayerState> player) : IGameSe
32 57 public Task ValidateRestartAsync(GameSessionTarget target, CancellationToken token)
33 58 {
34 59 token.ThrowIfCancellationRequested(); using var p = OpenVerified(target);
35 if (!File.Exists(target.Launcher)) throw new InvalidOperationException("未找到同一安装目录的官方启动器;未结束游戏。");
60 return ValidateLaunchAsync(target,token);
61 }
62 public Task ValidateLaunchAsync(GameSessionTarget target, CancellationToken token)
63 {
64 token.ThrowIfCancellationRequested();
65 if (!File.Exists(target.Executable) || !Path.GetFileName(target.Executable).Equals("EliteDangerous64.exe",StringComparison.OrdinalIgnoreCase))
66 throw new InvalidOperationException("游戏主程序路径无效,请重新选择 EliteDangerous64.exe。");
67 var expectedLauncher = Path.Combine(Directory.GetParent(target.Executable)?.Parent?.Parent?.FullName ?? "","EDLaunch.exe");
68 if (!File.Exists(target.Launcher) || !string.Equals(Path.GetFullPath(target.Launcher),expectedLauncher,StringComparison.OrdinalIgnoreCase))
69 throw new InvalidOperationException("未找到同一安装目录的官方启动器;请先确认游戏安装完整。");
36 70 if(target.Steam)_=SteamExecutable();
37 71 return Task.CompletedTask;
38 72 }
@@ -49,12 +83,15 @@ public sealed class WindowsGameSessionDriver(Func<PlayerState> player) : IGameSe
49 83 public Task<bool> HasExitedAsync(GameSessionTarget target, CancellationToken token)
50 84 {
51 85 token.ThrowIfCancellationRequested();
86 if (target.ProcessId == 0) return Task.FromResult(true);
52 87 try { using var p = Process.GetProcessById(target.ProcessId); return Task.FromResult(p.HasExited || p.StartTime.ToUniversalTime().Ticks != target.StartedUtcTicks); }
53 88 catch (ArgumentException) { return Task.FromResult(true); }
54 89 }
55 90 public async Task LaunchAsync(GameSessionTarget previous, CancellationToken token)
56 91 {
92 await ValidateLaunchAsync(previous,token);
57 93 if (!await HasExitedAsync(previous, token)) throw new InvalidOperationException("原进程尚未结束,不能启动第二个实例。");
94 EnsureNoRunningGame(token);
58 95 // The official launcher has a single-instance mutex. Its /autorun obtains fresh platform tickets.
59 96 var launchers = Process.GetProcessesByName("EDLaunch");
60 97 try
@@ -79,8 +116,16 @@ public sealed class WindowsGameSessionDriver(Func<PlayerState> player) : IGameSe
79 116 var start=new ProcessStartInfo(previous.Launcher){UseShellExecute=false,WorkingDirectory=Path.GetDirectoryName(previous.Launcher)!,WindowStyle=ProcessWindowStyle.Hidden};
80 117 foreach(var argument in plan.Arguments)start.ArgumentList.Add(argument);
81 118 foreach(var pair in plan.Environment)start.Environment[pair.Key]=pair.Value;
119 EnsureNoRunningGame(token);
82 120 using var launched = Process.Start(start) ?? throw new InvalidOperationException("无法启动官方启动器。");
83 121 }
122 private static void EnsureNoRunningGame(CancellationToken token)
123 {
124 token.ThrowIfCancellationRequested();
125 var games = Process.GetProcessesByName("EliteDangerous64");
126 try { if (games.Any(game => !game.HasExited)) throw new InvalidOperationException("游戏已经在运行,未重复启动。"); }
127 finally { foreach (var game in games) game.Dispose(); }
128 }
84 129 public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget previous, CancellationToken token)
85 130 {
86 131 token.ThrowIfCancellationRequested(); var games = Process.GetProcessesByName("EliteDangerous64");
Modified XFEEDToolBox/ViewModels/NavigationViewModel.cs +3 -3
@@ -67,7 +67,7 @@ public partial class ToolboxViewModel
67 67 if(progress.State!=NavigationRunState.Running)LogNavigation("导航结果",progress.Message);
68 68 RefreshNavigationStartHint();
69 69 StartNavigationCommand.NotifyCanExecuteChanged();StopNavigationCommand.NotifyCanExecuteChanged();
70 RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();
70 StartGameCommand.NotifyCanExecuteChanged();RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();
71 71 StartPlanCommand.NotifyCanExecuteChanged();
72 72 });
73 73 }
@@ -208,7 +208,7 @@ public partial class ToolboxViewModel
208 208 if(!CanStartNavigation())return;
209 209 using var startStop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);navigationStartStop=startStop;
210 210 NavigationActive=true;RefreshNavigationStartHint();StopNavigationCommand.NotifyCanExecuteChanged();
211 StartPlanCommand.NotifyCanExecuteChanged();RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();
211 StartGameCommand.NotifyCanExecuteChanged();StartPlanCommand.NotifyCanExecuteChanged();RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();
212 212 LogNavigation("启动导航",$"收到启动请求:{NavigationDestination.Trim()}");
213 213 try
214 214 {
@@ -222,7 +222,7 @@ public partial class ToolboxViewModel
222 222 catch(OperationCanceledException)when(lifetime.IsCancellationRequested){}
223 223 catch(OperationCanceledException)when(startStop.IsCancellationRequested){NavigationStatus="自动导航已停止。";NavigationStatusSeverity=InfoBarSeverity.Warning;LogNavigation("导航取消",NavigationStatus);}
224 224 catch(Exception e){NavigationStatus=e.Message;NavigationStatusSeverity=InfoBarSeverity.Error;LogNavigation("导航未启动",e.Message);}
225 finally{navigationStartStop=null;NavigationActive=false;RefreshNavigationStartHint();StartNavigationCommand.NotifyCanExecuteChanged();StopNavigationCommand.NotifyCanExecuteChanged();StartPlanCommand.NotifyCanExecuteChanged();RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();}
225 finally{navigationStartStop=null;NavigationActive=false;RefreshNavigationStartHint();StartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged();StopNavigationCommand.NotifyCanExecuteChanged();StartPlanCommand.NotifyCanExecuteChanged();RelogGameCommand.NotifyCanExecuteChanged();RestartGameCommand.NotifyCanExecuteChanged();}
226 226 }
227 227 private bool CanStopNavigation()=>NavigationActive;
228 228 [RelayCommand(CanExecute=nameof(CanStopNavigation))] private void StopNavigation(){navigationStartStop?.Cancel();runtime.Navigator.Stop();}
Modified XFEEDToolBox/ViewModels/ToolboxViewModel.cs +8 -6
@@ -41,7 +41,8 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
41 41 [ObservableProperty] public partial bool IsBusy { get; set; }
42 42 [ObservableProperty] public partial bool RunActive { get; set; }
43 43 [ObservableProperty] public partial bool SessionBusy { get; set; }
44 [ObservableProperty] public partial string SessionMessage { get; set; } = "大退直接结束游戏进程后重启。底层重进入口仍在验证,暂未启用。";
44 [ObservableProperty, NotifyPropertyChangedFor(nameof(HasSessionMessage))] public partial string SessionMessage { get; set; } = "";
45 public bool HasSessionMessage => !string.IsNullOrWhiteSpace(SessionMessage);
45 46 [ObservableProperty] public partial bool HotkeysEnabled { get; set; }
46 47 [ObservableProperty] public partial string RelogHotkey { get; set; } = "";
47 48 [ObservableProperty] public partial string RestartHotkey { get; set; } = "";
@@ -137,7 +138,7 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
137 138 public ToolboxViewModel(ToolboxRuntime runtime, DispatcherQueue dispatcher)
138 139 {
139 140 this.runtime = runtime; this.dispatcher = dispatcher;
140 sessions = new(new WindowsGameSessionDriver(() => runtime.Player), () => RunActive||NavigationActive);
141 sessions = new(new WindowsGameSessionDriver(() => runtime.Player, () => PluginGameExecutable), () => RunActive||NavigationActive);
141 142 sessions.Progress += p => dispatcher.TryEnqueue(() => { if (p.Message.Length > 0) { SessionMessage = p.Message; runtime.Log("会话", p.Message); } });
142 143 JournalDirectory = runtime.Settings.JournalDirectory; EddnEndpoint = runtime.Settings.EddnEndpoint;
143 144 EddnOnStartup = runtime.Settings.ConnectEddnOnStart;
@@ -206,7 +207,7 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
206 207 await ReadTranslationAsync();
207 208 await RefreshWalletAsync();
208 209 if(runtime.Settings.ShieldZeroRestartEnabled&&sessionAutomationAllowed)ShieldZeroRestartEnabled=true;
209 RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
210 StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
210 211 }
211 212
212 213 partial void OnDemoModeChanged(bool value)
@@ -215,7 +216,7 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
215 216 if (RunActive || SessionBusy) { ShowError("请先停止执行或会话操作,再切换数据模式。"); revertingDemo = true; DemoMode = !value; revertingDemo = false; return; }
216 217 Plan = null; SelectedStart = null; marketPage = 0;
217 218 UsePlayerValues(); OnRuntimeChanged();
218 RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
219 StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
219 220 }
220 221 partial void OnMarketSearchChanged(string value) { marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
221 222 partial void OnOnlyCurrentStationChanged(bool value) { marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
@@ -333,13 +334,14 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
333 334
334 335 partial void OnSessionBusyChanged(bool value)
335 336 {
336 RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
337 StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
337 338 StartPlanCommand.NotifyCanExecuteChanged(); CalculateCommand.NotifyCanExecuteChanged();
338 339 StartNavigationCommand.NotifyCanExecuteChanged();
339 340 }
340 partial void OnRunActiveChanged(bool value) { RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged(); }
341 partial void OnRunActiveChanged(bool value) { StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged(); }
341 342 private bool CanManageSession() => initialized && !DemoMode && !RunActive && !SessionBusy&&!NavigationActive;
342 343 private bool CanRelog() => CanManageSession() && sessions.CanRelog;
344 [RelayCommand(CanExecute = nameof(CanManageSession))] private Task StartGameAsync() => RunSessionAsync(SessionOperation.Start);
343 345 [RelayCommand(CanExecute = nameof(CanRelog))] private Task RelogGameAsync() => RunSessionAsync(SessionOperation.Relog);
344 346 [RelayCommand(CanExecute = nameof(CanManageSession))] private Task RestartGameAsync() => RunSessionAsync(SessionOperation.Restart);
345 347 [RelayCommand] private void CancelSession() => sessionStop?.Cancel();
Modified XFEEDToolBox/Views/AppShellPage.xaml +2 -2
@@ -6,10 +6,10 @@
6 6 <StackPanel x:Name="TitleActions" Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
7 7 <Button x:Name="UpgradeListButton" ToolTipService.ToolTip="全局改装清单" AutomationProperties.Name="全局改装清单" Background="Transparent" BorderThickness="0" Padding="4">
8 8 <Button.Content><Grid Width="36" Height="32"><FontIcon Glyph="&#xEADF;" FontSize="18"/><InfoBadge x:Name="UpgradeCountBadge" Value="{x:Bind ViewModel.UpgradeItemCount,Mode=OneWay}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,-4,-4,0" IsHitTestVisible="False" Visibility="{x:Bind ViewModel.HasUpgradeItems,Mode=OneWay,Converter={StaticResource BoolVisibility}}"/></Grid></Button.Content>
9 <Button.Flyout><Flyout x:Name="UpgradeListFlyout" Placement="BottomEdgeAlignedRight"><Flyout.FlyoutPresenterStyle><Style TargetType="FlyoutPresenter"><Setter Property="MaxWidth" Value="390"/><Setter Property="Padding" Value="14"/><Setter Property="Background" Value="{StaticResource PanelBrush}"/></Style></Flyout.FlyoutPresenterStyle>
9 <Button.Flyout><Flyout x:Name="UpgradeListFlyout" Placement="BottomEdgeAlignedRight"><Flyout.FlyoutPresenterStyle><Style TargetType="FlyoutPresenter"><Setter Property="MaxWidth" Value="390"/><Setter Property="Padding" Value="14"/><Setter Property="CornerRadius" Value="12"/><Setter Property="Background" Value="{StaticResource PanelBrush}"/></Style></Flyout.FlyoutPresenterStyle>
10 10 <StackPanel Width="340" Spacing="10">
11 11 <Grid ColumnSpacing="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="全局改装清单" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/><Button Grid.Column="1" x:Name="ClearUpgradeListButton" Content="清空清单" Command="{x:Bind ViewModel.ClearUpgradeListCommand}" FontSize="12" Padding="8,4"/></Grid>
12 <TextBlock Text="{x:Bind ViewModel.UpgradeSummary,Mode=OneWay}" FontSize="12" TextWrapping="Wrap"/><TextBlock Text="{x:Bind ViewModel.UpgradeCreditSummary,Mode=OneWay}" TextWrapping="Wrap" Foreground="{StaticResource TealBrush}" FontSize="11"/><ProgressRing Width="20" Height="20" IsActive="{x:Bind ViewModel.UpgradeBusy,Mode=OneWay}" Visibility="{x:Bind ViewModel.UpgradeBusy,Mode=OneWay,Converter={StaticResource BoolVisibility}}"/><ScrollViewer x:Name="UpgradePreviewScroller" MaxHeight="300" VerticalScrollBarVisibility="Auto"><ItemsControl ItemsSource="{x:Bind ViewModel.UpgradeNeeds}" ItemTemplate="{StaticResource CompactMaterialNeedTemplate}"/></ScrollViewer><Button Content="查看详情 →" Click="OpenUpgradeDetails" HorizontalAlignment="Stretch"/>
12 <TextBlock Text="{x:Bind ViewModel.UpgradeSummary,Mode=OneWay}" FontSize="12" TextWrapping="Wrap"/><TextBlock Text="{x:Bind ViewModel.UpgradeCreditSummary,Mode=OneWay}" TextWrapping="Wrap" Foreground="{StaticResource TealBrush}" FontSize="11"/><ProgressRing Width="20" Height="20" IsActive="{x:Bind ViewModel.UpgradeBusy,Mode=OneWay}" Visibility="{x:Bind ViewModel.UpgradeBusy,Mode=OneWay,Converter={StaticResource BoolVisibility}}"/><ScrollViewer x:Name="UpgradePreviewScroller" MaxHeight="300" Padding="0,0,20,0" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" HorizontalScrollMode="Disabled" HorizontalContentAlignment="Stretch"><ItemsControl ItemsSource="{x:Bind ViewModel.UpgradeNeeds}" ItemTemplate="{StaticResource CompactMaterialNeedTemplate}"/></ScrollViewer><Button Content="查看详情 →" Click="OpenUpgradeDetails" HorizontalAlignment="Stretch"/>
13 13 </StackPanel>
14 14 </Flyout></Button.Flyout>
15 15 </Button>
Modified XFEEDToolBox/Views/MainPage.xaml +2 -2
@@ -3,8 +3,9 @@
3 3 <StackPanel x:Name="DashboardContent" Spacing="16" Padding="0,8,8,24" HorizontalAlignment="Stretch">
4 4 <Grid ColumnSpacing="18"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
5 5 <StackPanel Spacing="5"><TextBlock Text="指挥官航行中心" Style="{StaticResource Eyebrow}"/><TextBlock Text="航行概览" Style="{StaticResource PageTitle}" FontSize="32"/><TextBlock Text="指挥官,你的下一段航程从这里开始。" Style="{StaticResource MutedText}"/></StackPanel>
6 <StackPanel Grid.Column="1" Spacing="8" VerticalAlignment="Center" HorizontalAlignment="Right"><Border CornerRadius="18" BorderBrush="#3E9C9F" BorderThickness="1" Background="#102D35" Padding="13,7"><StackPanel Orientation="Horizontal" Spacing="8"><Ellipse Width="7" Height="7" Fill="{StaticResource TealBrush}"/><TextBlock Text="{x:Bind ViewModel.HomeDataState,Mode=OneWay}" FontSize="12" Foreground="{StaticResource TealBrush}"/></StackPanel></Border><TextBlock Text="{x:Bind ViewModel.HomeUpdated,Mode=OneWay}" FontSize="11" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Right" ToolTipService.ToolTip="游戏最近一次写出数据的时间"/></StackPanel>
6 <StackPanel Grid.Column="1" Spacing="8" VerticalAlignment="Center" HorizontalAlignment="Right"><Border CornerRadius="18" BorderBrush="#3E9C9F" BorderThickness="1" Background="#102D35" Padding="13,7"><StackPanel Orientation="Horizontal" Spacing="8"><Ellipse Width="7" Height="7" Fill="{StaticResource TealBrush}"/><TextBlock Text="{x:Bind ViewModel.HomeDataState,Mode=OneWay}" FontSize="12" Foreground="{StaticResource TealBrush}"/></StackPanel></Border><TextBlock Text="{x:Bind ViewModel.HomeUpdated,Mode=OneWay}" FontSize="11" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Right" ToolTipService.ToolTip="游戏最近一次写出数据的时间"/><Button x:Name="StartGameButton" Command="{x:Bind ViewModel.StartGameCommand}" Style="{StaticResource PrimaryButton}" HorizontalAlignment="Right" Padding="14,8" AutomationProperties.Name="启动游戏" ToolTipService.ToolTip="通过官方启动器,以非 VR 模式启动"><StackPanel Orientation="Horizontal" Spacing="8"><FontIcon Glyph="&#xE768;" FontSize="14"/><TextBlock Text="启动游戏"/></StackPanel></Button></StackPanel>
7 7 </Grid>
8 <Grid ColumnSpacing="12" Visibility="{x:Bind ViewModel.HasSessionMessage,Mode=OneWay,Converter={StaticResource BoolVisibility}}"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="{x:Bind ViewModel.SessionMessage,Mode=OneWay}" Foreground="{StaticResource TealBrush}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/><Button Grid.Column="1" Content="取消当前操作" Command="{x:Bind ViewModel.CancelSessionCommand}" Visibility="{x:Bind ViewModel.SessionBusy,Mode=OneWay,Converter={StaticResource BoolVisibility}}"/></Grid>
8 9 <Border Style="{StaticResource DashboardCard}" Padding="0"><Grid>
9 10 <Grid.Background><ImageBrush ImageSource="/Assets/Home/navigation-orbit.png" Stretch="UniformToFill" Opacity=".7"/></Grid.Background>
10 11 <Rectangle IsHitTestVisible="False"><Rectangle.Fill><LinearGradientBrush StartPoint="0,0" EndPoint="1,0"><GradientStop Color="#FF12202E" Offset="0"/><GradientStop Color="#EC12202E" Offset=".5"/><GradientStop Color="#4012202E" Offset="1"/></LinearGradientBrush></Rectangle.Fill></Rectangle>
@@ -40,7 +41,6 @@
40 41 <Button Command="{x:Bind ViewModel.RelogGameCommand}" Style="{StaticResource DashboardAction}"><Grid ColumnSpacing="14"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><FontIcon Glyph="&#xE72C;" FontSize="25"/><StackPanel Grid.Column="1" Spacing="5"><TextBlock Text="一键重进" FontWeight="SemiBold"/><TextBlock Text="返回主菜单 · 接口验证中" Style="{StaticResource MutedText}" FontSize="11"/></StackPanel><FontIcon Grid.Column="2" Glyph="&#xE76C;" FontSize="12"/></Grid></Button>
41 42 <Button Command="{x:Bind ViewModel.RestartGameCommand}" Style="{StaticResource DashboardAction}"><Grid ColumnSpacing="14"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><FontIcon Glyph="&#xE8BB;" FontSize="25"/><StackPanel Grid.Column="1" Spacing="5"><TextBlock Text="一键大退" FontWeight="SemiBold"/><TextBlock Text="结束游戏进程后重新启动" Style="{StaticResource MutedText}" FontSize="11"/></StackPanel><FontIcon Grid.Column="2" Glyph="&#xE76C;" FontSize="12"/></Grid></Button>
42 43 <Grid ColumnSpacing="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="{x:Bind ViewModel.HotkeyStatus,Mode=OneWay}" Style="{StaticResource MutedText}" FontSize="11" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" ToolTipService.ToolTip="{x:Bind ViewModel.HotkeyStatus,Mode=OneWay}"/><HyperlinkButton Grid.Column="1" Content="快捷键设置" Click="OpenSettings" Padding="0,4" FontSize="11"/></Grid>
43 <Button Content="取消当前操作" Command="{x:Bind ViewModel.CancelSessionCommand}" Visibility="{x:Bind ViewModel.SessionBusy,Mode=OneWay,Converter={StaticResource BoolVisibility}}"/>
44 44 <Border BorderBrush="{StaticResource PanelStrokeBrush}" BorderThickness="0,1,0,0" Padding="0,14,0,0"><StackPanel Spacing="7">
45 45 <ToggleSwitch x:Name="ShieldRestartToggle" Header="护盾归零自动重启" IsOn="{x:Bind ViewModel.ShieldZeroRestartEnabled,Mode=TwoWay}" OnContent="已开启" OffContent="已关闭"/>
46 46 <TextBlock Text="护盾失效时,直接结束当前游戏进程并通过官方启动器重启。" Style="{StaticResource MutedText}" FontSize="11"/>
Modified XFEEDToolBox/XFEEDToolBox.csproj +1 -1
@@ -2,7 +2,7 @@
2 2 <PropertyGroup>
3 3 <OutputType>WinExe</OutputType>
4 4 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
5 <Version>0.4.13</Version>
5 <Version>0.4.14</Version>
6 6 <TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
7 7 <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
8 8 <ApplicationManifest>app.manifest</ApplicationManifest>
Modified docs/INTERFACE-0.4.md +2 -0
@@ -1,5 +1,7 @@
1 1 # 0.4 界面与改装清单
2 2
3 0.4.14:主页顶部增加“启动游戏”,通过官方启动器进入非 VR 模式,并在主页显示操作结果。全局清单 Flyout 使用 12 像素圆角;材料预览右侧为滚动条保留 20 像素内边距,禁止横向滚动,缺口数字完整显示。
4
3 5 主页先使用内置 ImageGen 生成设计图,再按图实现 WinUI。设计原图为 `design/homepage-concept-v1.png`,完整提示词为 `design/homepage-imagegen-prompt.txt`。背景素材与提示词分别保存在 `XFEEDToolBox/Assets/Home/navigation-orbit.png` 和 `design/homepage-orbit-imagegen-prompt.txt`。背景仅用于横幅;星图上的星系坐标来自本机日志、市场坐标和 EDSM,未随机生成星系。
4 6
5 7 名称由工具箱内置的中文词库呈现,不依赖游戏汉化是否安装。飞船、模块、槽位、蓝图、实验效果、工程师和改装参数使用中文主名称与小字英文原名;内部标识仍保留以供匹配配方和日志。实验效果按原始 ID 查英文,避免将游戏已汉化的文本误当作英文。材料和商品名称保持原始数据名称。材料分类使用原子、工程齿轮、数据库图标,仓库和各处需求表共用。
Modified docs/SESSION-CONTROLS.md +6 -0
@@ -1,5 +1,11 @@
1 1 # 会话控制与全局快捷键
2 2
3 ## 启动游戏
4
5 0.4.14 在主页顶部提供“启动游戏”。使用游戏插件设置中的主程序路径(首次使用会自动发现 Steam 安装),核对同一安装目录的 `EDLaunch.exe`,通过现有官方启动参数以 `/novr` 桌面模式启动。启动请求和重启共用单操作门禁及取消入口,但启动流程不调用进程终止。游戏已经运行时只显示状态,不重复打开游戏或启动器;验证及实际提交启动之间会再次检查是否出现游戏实例。
6
7 启动后等待同一游戏 EXE 路径的进程确认,超时显示启动器登录或更新提示。主页直接显示操作进度;测试使用模拟会话驱动验证启动、已运行、缺失启动器、取消、重复点击和错误安装路径,未为此启动、退出或重启正在游玩的游戏。
8
3 9 ## 一键大退
4 10
5 11 主页按钮或已启用的全局快捷键触发。先识别唯一标准游戏实例,再核对 PID、启动时间、完整 EXE 路径和进程名称。确认同一安装目录的官方启动器存在后,调用 `Process.Kill(entireProcessTree: false)` 直接结束选定游戏进程。等待其退出,再启动同一安装目录的官方启动器,并等待同一 EXE 路径的新游戏进程作为成功证据。超时不会当作已重启成功。
Modified scripts/Publish.ps1 +1 -1