返回提交历史
Modified
README.md
+2
-2
Modified
Start-Toolbox.cmd
+4
-0
Modified
XFEEDToolBox.Core/Session/GameSessionController.cs
+27
-15
Modified
XFEEDToolBox.Test/SessionTests.cs
+49
-3
Modified
XFEEDToolBox/Services/WindowsGameSessionDriver.cs
+49
-4
Modified
XFEEDToolBox/ViewModels/NavigationViewModel.cs
+3
-3
Modified
XFEEDToolBox/ViewModels/ToolboxViewModel.cs
+8
-6
Modified
XFEEDToolBox/Views/AppShellPage.xaml
+2
-2
Modified
XFEEDToolBox/Views/MainPage.xaml
+2
-2
Modified
XFEEDToolBox/XFEEDToolBox.csproj
+1
-1
Modified
docs/INTERFACE-0.4.md
+2
-0
Modified
docs/SESSION-CONTROLS.md
+6
-0
Modified
scripts/Publish.ps1
+1
-1
XFEstudio/XFEEDToolBox
feat: add desktop game launch and refine upgrade flyout (0.4.14)
1128292
代码差异
13 个文件
+156
-39
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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");
@@ -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();}
@@ -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();
@@ -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="" 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>
@@ -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="" 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="" 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="" 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="" 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="" 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"/>
@@ -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>
@@ -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 查英文,避免将游戏已汉化的文本误当作英文。材料和商品名称保持原始数据名称。材料分类使用原子、工程齿轮、数据库图标,仓库和各处需求表共用。
@@ -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 路径的新游戏进程作为成功证据。超时不会当作已重启成功。