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

XFEWarframe

【WinUI】基于Warframe游戏本体的公开服务器以及安装器

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

XFEstudio/XFEWarframe

新增 Native Bridge 构建/验证;改进 PNG 解码与安装器

- 架构/发布:加入 BridgePayload.targets(Build 后构建原生 Bridge,Publish 时包含 Bridge/payload.json 与 DLL);在 XFEWarframe.*.csproj 导入 targets;Directory.Build.props 排除 artifacts。 - Bridge 构建脚本:重写 scripts/Build-Bridge.ps1,增加并发锁、输入指纹、artifacts 缓存、IfNeeded 模式;Build/Publish 调用 -IfNeeded;文档(DEBUGGING/INSTALLER/EXPERIMENTAL-ABILITIES)与脚本同步更新。 - 安装器强化:新增 FindPayload(string)/ReadPayloadManifest,对 payload.json schema/version/SHA-256/ExportRva 做早期校验;Install 在修改游戏前验证 payload 并在失败时不更改游戏目录;RequireCurrentBridge 错误信息更明确;新增 InstallerPayloadTests 覆盖发现/旧版本/缺失 DLL/错误 SHA 场景。 - UI 图像控件改进:GameAssetImage 引入 SoftwareBitmap 解码路径(后台使用 BitmapDecoder.GetSoftwareBitmapAsync,UI 线程创建 WriteableBitmap),增加 generation/pixels/PixelWidth/PixelHeight/IsCached/Source,改进 Refresh、异常分类与资源释放;新增 SetDragPreview 用于拖放预览,适配 ModLoadoutView。 - 验证与测试工具:新增 GameAssetImageVerification(异步验证缓存命中、资源加载、过期/取消请求、不跨线程异常等);将图像加载检查并入 DesktopVerification 与 EquipmentEditorVerification,并在运行时断言 payload.json 与运行时版本/SHA 对应。 - 其它:若干本地化与错误提示调整,发布/构建/安装脚本、文档与测试保持一致的 Bridge 版本与校验流程。 效果:自动化并缓存原生 Bridge 构建与发布;用更安全可测的方式在后台解码/缓存 PNG,消除跨线程与隐蔽错误;在安装器中提前验证 payload 以避免破坏游戏,并补充相关自动化测试。

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

代码差异

20 个文件 +334 -40
Modified Admin/XFEWarframe.Admin/Utilities/Diagnostics/DesktopVerification.cs +1 -0
@@ -337,6 +337,7 @@ internal static class DesktopVerification
337 337 async () => { var current = (EquipmentPage)navigation.NavigationService.Frame!.Content; if (current.ViewModel.LoadCommand.ExecutionTask is Task load) await load; return (current, (EquipmentEditorView)current.FindName("EditorView")); },
338 338 () => navigation.NavigationService.GoBack(), liveInput);
339 339 checks.Add("exalted-equipment: host-to-Glory shared card board, Warframe/Glory install/swap/uninstall, raw-to-instance conversion, C save, independent B, back retains host");
340 checks.Add("image-loading: native WinUI PNG decode, cache miss/hit, resource icon, stale request protection, no wrong-thread dependency-property reads");
340 341 checks.Add("mod-search: original descriptions, Viral includes Cold/Toxin, matching suggestions, AND keywords, cancelled stale request cannot replace results");
341 342 navigation.NavigationService.GoBack();
342 343 if (navigation.NavigationService.Frame.Content is not EditorPage returned) throw new InvalidOperationException("Native Back did not return to the inventory list.");
Added Client/BridgePayload.targets +21 -0
@@ -0,0 +1,21 @@
1 <Project>
2 <PropertyGroup>
3 <_XfeBridgeScript>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../scripts/Build-Bridge.ps1'))</_XfeBridgeScript>
4 </PropertyGroup>
5 <Target Name="BuildBundledBridgePayload" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">
6 <Exec Command="pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File &quot;$(_XfeBridgeScript)&quot; -Configuration &quot;$(Configuration)&quot; -IfNeeded -Destination &quot;$(TargetDir)Bridge&quot;" />
7 </Target>
8 <Target Name="PublishBundledBridgePayload" AfterTargets="ComputeFilesToPublish">
9 <Error Condition="!Exists('$(TargetDir)Bridge/payload.json') or !Exists('$(TargetDir)Bridge/XFEWarframe.Bridge.dll')" Text="The bundled native component was not built. Build this application before publishing." />
10 <ItemGroup>
11 <ResolvedFileToPublish Include="$(TargetDir)Bridge/payload.json">
12 <RelativePath>Bridge/payload.json</RelativePath>
13 <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
14 </ResolvedFileToPublish>
15 <ResolvedFileToPublish Include="$(TargetDir)Bridge/XFEWarframe.Bridge.dll">
16 <RelativePath>Bridge/XFEWarframe.Bridge.dll</RelativePath>
17 <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
18 </ResolvedFileToPublish>
19 </ItemGroup>
20 </Target>
21 </Project>
Modified Client/SharedUI/EquipmentEditorVerification.cs +1 -0
@@ -16,6 +16,7 @@ internal static class EquipmentEditorVerification
16 16 const string glory = "/Lotus/Powersuits/Choir/ChoirEruptWeapon";
17 17 const string jade = "/Lotus/Powersuits/Choir/Abilities/ChoirEruptAbility";
18 18 const string hornet = "/Lotus/Upgrades/Mods/Pistol/WeaponDamageAmountMod";
19 await GameAssetImageVerification.VerifyAsync(view);
19 20 await grantMod();
20 21 var model = view.Model!;
21 22 await model.RefreshCommand.ExecuteAsync(null);
Modified Client/SharedUI/GameAssetImage.cs +35 -9
@@ -2,6 +2,7 @@ using Microsoft.UI.Xaml;
2 2 using Microsoft.UI.Xaml.Controls;
3 3 using Microsoft.UI.Xaml.Media;
4 4 using Microsoft.UI.Xaml.Media.Imaging;
5 using Windows.Graphics.Imaging;
5 6 using XFEWarframe.Client;
6 7
7 8 namespace XFEWarframe.Desktop.Editing;
@@ -12,33 +13,58 @@ public sealed class GameAssetImage : Grid
12 13 private readonly Image image = new() { Stretch = Stretch.Uniform };
13 14 private readonly ProgressRing loading = new() { Width = 20, Height = 20 };
14 15 private int generation;
16 private SoftwareBitmap? pixels;
15 17 public string Resource { get => (string)GetValue(ResourceProperty); set => SetValue(ResourceProperty, value); }
16 18 public static readonly DependencyProperty ResourceProperty = DependencyProperty.Register(nameof(Resource), typeof(string), typeof(GameAssetImage), new PropertyMetadata("", (s, _) => ((GameAssetImage)s).Refresh()));
17 19 public string CacheKey { get => (string)GetValue(CacheKeyProperty); set => SetValue(CacheKeyProperty, value); }
18 20 public static readonly DependencyProperty CacheKeyProperty = DependencyProperty.Register(nameof(CacheKey), typeof(string), typeof(GameAssetImage), new PropertyMetadata("", (s, _) => ((GameAssetImage)s).Refresh()));
19 21 public bool IsCached { get; private set; }
22 public int PixelWidth { get; private set; }
23 public int PixelHeight { get; private set; }
20 24 public ImageSource? Source => image.Source;
21 25 public event EventHandler? ImageResolved;
22 26 public GameAssetImage() { Children.Add(image); Children.Add(loading); Loaded += (_, _) => Refresh(); Unloaded += (_, _) => generation++; }
23 27 public async void Refresh()
24 28 {
29 // Dependency properties belong to the UI thread. Background cache work
30 // must use snapshots, never read the control from a Task.Run callback.
31 var cacheKey = CacheKey;
32 var resource = Resource;
25 33 var observed = ++generation; image.Source = null; IsCached = false;
26 loading.IsActive = Resource.Length > 0 || CacheKey.Length > 0; loading.Visibility = loading.IsActive ? Visibility.Visible : Visibility.Collapsed;
34 pixels?.Dispose(); pixels = null; PixelWidth = PixelHeight = 0;
35 loading.IsActive = resource.Length > 0 || cacheKey.Length > 0; loading.Visibility = loading.IsActive ? Visibility.Visible : Visibility.Collapsed;
36 SoftwareBitmap? decoded = null;
27 37 try
28 38 {
29 var path = CacheKey.Length > 0 ? await Task.Run(() => VisualAssetCache.Find(CacheKey)) : Resource.Length > 0 ? await VisualAssetCache.IconAsync(Resource) : null;
39 var path = cacheKey.Length > 0 ? await Task.Run(() => VisualAssetCache.Find(cacheKey)) : resource.Length > 0 ? await Task.Run(() => VisualAssetCache.IconAsync(resource)) : null;
30 40 if (observed != generation) return;
31 41 if (path is not null)
32 42 {
33 // Decode asynchronously with WinUI, then let the control release its file stream.
34 var bitmap = new BitmapImage();
35 using var stream = await Windows.Storage.Streams.FileRandomAccessStream.OpenAsync(path, Windows.Storage.FileAccessMode.Read);
36 await bitmap.SetSourceAsync(stream);
43 // Decode through WIC off the dispatcher. XAML's async image upload
44 // can wait for a render pass when a window or item is not visible.
45 decoded = await Task.Run(async () =>
46 {
47 using var stream = await Windows.Storage.Streams.FileRandomAccessStream.OpenAsync(path, Windows.Storage.FileAccessMode.Read);
48 var decoder = await BitmapDecoder.CreateAsync(stream);
49 return await decoder.GetSoftwareBitmapAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
50 });
37 51 if (observed != generation) return;
38 image.Source = bitmap; IsCached = true;
52 var bitmap = new WriteableBitmap(decoded.PixelWidth, decoded.PixelHeight);
53 decoded.CopyToBuffer(bitmap.PixelBuffer);
54 bitmap.Invalidate();
55 image.Source = bitmap;
56 pixels = decoded; decoded = null;
57 PixelWidth = pixels.PixelWidth; PixelHeight = pixels.PixelHeight;
58 IsCached = true;
39 59 }
40 60 }
41 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.COMException) { }
42 finally { if (observed == generation) { loading.IsActive = false; loading.Visibility = Visibility.Collapsed; ImageResolved?.Invoke(this, EventArgs.Empty); } }
61 catch (OperationCanceledException) when (observed != generation) { }
62 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.COMException)
63 { if (observed == generation) image.Source = null; }
64 finally { decoded?.Dispose(); if (observed == generation) { loading.IsActive = false; loading.Visibility = Visibility.Collapsed; ImageResolved?.Invoke(this, EventArgs.Empty); } }
65 }
66 public void SetDragPreview(DragUI preview)
67 {
68 if (IsCached && pixels is not null) preview.SetContentFromSoftwareBitmap(pixels, new Windows.Foundation.Point(0, 0));
43 69 }
44 70 }
Added Client/SharedUI/GameAssetImageVerification.cs +71 -0
@@ -0,0 +1,71 @@
1 using System.Runtime.ExceptionServices;
2 using System.Runtime.InteropServices;
3 using Microsoft.UI.Xaml.Controls;
4 using Microsoft.UI.Xaml.Media.Imaging;
5 using XFEWarframe.Client;
6
7 namespace XFEWarframe.Desktop.Editing;
8
9 internal static class GameAssetImageVerification
10 {
11 internal static async Task VerifyAsync(Panel host)
12 {
13 var resource = "/Lotus/Interface/XFEVerification/" + Guid.NewGuid().ToString("N") + ".png";
14 var cacheKey = VisualAssetCache.Key("icon", resource);
15 var missingKey = VisualAssetCache.Key("verification-missing", Guid.NewGuid().ToString("N"));
16 string? fixture = null;
17 var wrongThread = 0;
18 // Catch swallowed first-chance failures too: missing images must not make
19 // a wrong-thread dependency-property read look like a successful fallback.
20 EventHandler<FirstChanceExceptionEventArgs> capture = (_, args) =>
21 {
22 if (args.Exception is COMException { HResult: unchecked((int)0x8001010E) }) Interlocked.Increment(ref wrongThread);
23 };
24 AppDomain.CurrentDomain.FirstChanceException += capture;
25 var control = new GameAssetImage { Width = 1, Height = 1 };
26 try
27 {
28 host.Children.Add(control);
29 host.UpdateLayout();
30 async Task ResolveAsync(Action request)
31 {
32 var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
33 var onUiThread = false;
34 EventHandler completed = (_, _) => { onUiThread = control.DispatcherQueue.HasThreadAccess; completion.TrySetResult(); };
35 control.ImageResolved += completed;
36 try
37 {
38 request();
39 try { await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); }
40 catch (TimeoutException ex)
41 {
42 throw new InvalidOperationException($"Image loading stalled: controlLoaded={control.IsLoaded}, hostLoaded={host.IsLoaded}, source={control.Source?.GetType().Name ?? "none"}, cached={control.IsCached}, wrongThread={Volatile.Read(ref wrongThread)}.", ex);
43 }
44 if (!onUiThread) throw new InvalidOperationException("Image completion was raised away from the WinUI dispatcher.");
45 if (Volatile.Read(ref wrongThread) != 0) throw new InvalidOperationException("Image loading accessed a WinUI object from a worker thread (0x8001010E).");
46 }
47 finally { control.ImageResolved -= completed; }
48 }
49
50 await ResolveAsync(() => control.CacheKey = missingKey);
51 if (control.IsCached || control.Source is not null) throw new InvalidOperationException("A missing image did not retain its placeholder.");
52 fixture = await Task.Run(() => VisualAssetCache.StoreAsync(cacheKey, PngImages.CropRgba([30, 90, 160, 255], 1, 1, 0, 0, 1, 1)));
53 await ResolveAsync(() => control.CacheKey = cacheKey);
54 if (!control.IsCached || control.Source is not WriteableBitmap || control.PixelWidth != 1 || control.PixelHeight != 1)
55 throw new InvalidOperationException("The cached PNG was not decoded by the actual WinUI image control.");
56
57 await ResolveAsync(() => { control.Resource = resource; control.CacheKey = ""; });
58 if (!control.IsCached || control.Source is not WriteableBitmap || control.PixelWidth != 1 || control.PixelHeight != 1)
59 throw new InvalidOperationException("The cached resource icon did not load through WinUI.");
60
61 await ResolveAsync(() => { control.CacheKey = cacheKey; control.CacheKey = missingKey; });
62 if (control.IsCached || control.Source is not null) throw new InvalidOperationException("An obsolete image request replaced the current placeholder.");
63 }
64 finally
65 {
66 AppDomain.CurrentDomain.FirstChanceException -= capture;
67 host.Children.Remove(control);
68 if (fixture is not null) File.Delete(fixture); // Only the uniquely named fixture created above.
69 }
70 }
71 }
Modified Client/SharedUI/ModLoadoutView.cs +2 -2
@@ -206,7 +206,7 @@ public sealed class ModLoadoutView : Grid
206 206 {
207 207 var tile = new Grid { Width = width, Height = height, Tag = card, CanDrag = true };
208 208 var key = VisualAssetCache.Key("card-compact", card.Type, card.Fingerprint, UiText.Language);
209 var image = new GameAssetImage { CacheKey = key };
209 var image = new GameAssetImage { Width = width, Height = height, CacheKey = key };
210 210 var placeholder = new StackPanel { Spacing = 6, Margin = new Thickness(8), VerticalAlignment = VerticalAlignment.Center };
211 211 placeholder.Children.Add(new TextBlock { Text = card.Name, TextWrapping = TextWrapping.Wrap, MaxLines = 2 });
212 212 placeholder.Children.Add(new TextBlock { Text = UiText.Format("等级 {0} · 消耗 {1}", card.Rank, Math.Abs(card.Drain)), Opacity = .7, FontSize = 12 });
@@ -215,7 +215,7 @@ public sealed class ModLoadoutView : Grid
215 215 tile.Children.Add(image); tile.Children.Add(placeholder);
216 216 AutomationProperties.SetName(tile, card.Name + " · " + card.Rank); AutomationProperties.SetAutomationId(tile, "mod-card-" + card.Key);
217 217 ToolTipService.SetToolTip(tile, card.Name + " · " + UiText.Format("等级 {0} · 消耗 {1}", card.Rank, Math.Abs(card.Drain)));
218 tile.DragStarting += (_, e) => { StartDrag(e, card.Key, source); if (!e.Cancel && image.Source is BitmapImage bitmap) e.DragUI.SetContentFromBitmapImage(bitmap); }; return tile;
218 tile.DragStarting += (_, e) => { StartDrag(e, card.Key, source); if (!e.Cancel) image.SetDragPreview(e.DragUI); }; return tile;
219 219 }
220 220 public Task SearchAsync(string query)
221 221 {
Added Client/Tests/XFEWarframe.Client.Tests/InstallerPayloadTests.cs +73 -0
@@ -0,0 +1,73 @@
1 using System.Security.Cryptography;
2 using System.Text.Json;
3 using Xunit;
4
5 namespace XFEWarframe.Client.Tests;
6
7 public sealed class InstallerPayloadTests : IDisposable
8 {
9 private readonly string root = Path.Combine(Path.GetTempPath(), "xfe-payload-test-" + Guid.NewGuid().ToString("N"));
10 public InstallerPayloadTests() => Directory.CreateDirectory(root);
11 public void Dispose() => Directory.Delete(root, true);
12
13 private string Bundle(string directory, string version, int schema = 1)
14 {
15 var bundle = Path.Combine(directory, "Bridge");
16 Directory.CreateDirectory(bundle);
17 File.WriteAllBytes(Path.Combine(bundle, ClientInstaller.BridgeFile), [1, 2, 3]);
18 File.WriteAllText(Path.Combine(bundle, "payload.json"), JsonSerializer.Serialize(
19 new PayloadManifest { SchemaVersion = schema, Version = version, Sha256 = Convert.ToHexString(SHA256.HashData(new byte[] { 1, 2, 3 })) },
20 ClientJson.Default.PayloadManifest));
21 return bundle;
22 }
23
24 [Fact]
25 public void AutomaticDiscoverySkipsOldInstallerBundleAndFindsCurrentParentBundle()
26 {
27 var application = Path.Combine(root, "Installer");
28 Bundle(application, "1.4.2");
29 var current = Bundle(root, "1.4.4");
30 Assert.Equal(current, ClientInstaller.FindPayload(application));
31 }
32
33 [Fact]
34 public void AutomaticDiscoveryReportsOldSourceDirectoryWhenNoCurrentBundleExists()
35 {
36 var old = Bundle(root, "1.4.2");
37 var error = Assert.Throws<InvalidDataException>(() => ClientInstaller.FindPayload(root));
38 Assert.Contains(old, error.Message);
39 Assert.Contains("所选兼容组件目录版本过旧", error.Message);
40 Assert.DoesNotContain("安装与维护", error.Message);
41 }
42
43 [Fact]
44 public void AutomaticDiscoverySkipsUnsupportedManifestAndMissingDll()
45 {
46 var application = Path.Combine(root, "Installer");
47 var local = Bundle(application, "1.4.4", 2);
48 var current = Bundle(root, "1.4.4");
49 Assert.Equal(current, ClientInstaller.FindPayload(application));
50 Bundle(application, "1.4.4");
51 File.Delete(Path.Combine(local, ClientInstaller.BridgeFile));
52 Assert.Equal(current, ClientInstaller.FindPayload(application));
53 }
54
55 [Theory]
56 [InlineData("1.4.2", "版本过旧")]
57 [InlineData("1.4.4", "SHA-256")]
58 public void InvalidSourceIsRejectedBeforeCreatingGameInstallationFiles(string version, string message)
59 {
60 var payload = Bundle(Path.Combine(root, "Payload"), version);
61 if (version == "1.4.4") File.WriteAllBytes(Path.Combine(payload, ClientInstaller.BridgeFile), [7, 8, 9]);
62 var game = Path.Combine(root, "Game");
63 Directory.CreateDirectory(game);
64 var executable = Path.Combine(game, "Warframe.x64.exe");
65 File.WriteAllText(executable, "unchanged game fixture");
66 var before = Compatibility.Hash(executable);
67 var error = Assert.Throws<InvalidDataException>(() => new ClientInstaller().Install(game, "http://127.0.0.1:8444", payload));
68 Assert.Contains(message, error.Message);
69 Assert.Contains(payload, error.Message);
70 Assert.Equal(before, Compatibility.Hash(executable));
71 Assert.False(Directory.Exists(Path.Combine(game, "XFEWarframe")));
72 }
73 }
Modified Client/XFEWarframe.Client/ClientInstaller.cs +35 -13
@@ -19,17 +19,44 @@ public sealed class ClientInstaller
19 19 public static readonly string[] LegacyProxies = ["dwmapi.dll", "wtsapi32.dll", "version.dll"];
20 20 public static string Managed(string game, string name) => SafePath.Within(game, "XFEWarframe/" + name);
21 21
22 public static string FindPayload()
22 public static string FindPayload() => FindPayload(AppContext.BaseDirectory);
23 internal static string FindPayload(string applicationDirectory)
23 24 {
24 foreach (var basePath in new[] { AppContext.BaseDirectory, Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..")) })
25 var problems = new List<string>();
26 foreach (var basePath in new[] { Path.GetFullPath(applicationDirectory), Path.GetFullPath(Path.Combine(applicationDirectory, "..")) })
25 27 {
26 var path = Path.Combine(basePath, "bridge");
27 if (File.Exists(Path.Combine(path, "payload.json"))) return path;
28 var path = Path.Combine(basePath, "Bridge");
29 if (!File.Exists(Path.Combine(path, "payload.json"))) continue;
30 try
31 {
32 _ = ReadPayloadManifest(path);
33 if (!File.Exists(Path.Combine(path, BridgeFile))) throw new FileNotFoundException("兼容组件 DLL 缺失:" + path);
34 return path;
35 }
36 catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException) { problems.Add(ex.Message); }
28 37 }
38 if (problems.Count > 0) throw new InvalidDataException(string.Join(Environment.NewLine, problems));
29 39 throw new FileNotFoundException("未找到兼容组件,请使用完整发行包,或指定 --payload 目录。");
30 40 }
41 private static PayloadManifest ReadPayloadManifest(string payload)
42 {
43 var metadata = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(payload, "payload.json")), ClientJson.Default.PayloadManifest)
44 ?? throw new InvalidDataException("兼容组件清单为空:" + payload);
45 if (metadata.SchemaVersion != 1) throw new InvalidDataException("兼容组件清单版本不支持:" + payload);
46 RequireCurrentBridge(metadata.Version, payload);
47 return metadata;
48 }
31 49 public CompatibilityReport Install(string game, string server, string payload, IProgress<string>? progress = null, CancellationToken cancel = default)
32 50 {
51 // Validate the source component before touching the game or recovering
52 // its transaction. Repair must not keep reinstalling an obsolete bundle.
53 payload = Path.GetFullPath(payload);
54 var metadata = ReadPayloadManifest(payload);
55 var bridgeBytes = File.ReadAllBytes(Path.Combine(payload, BridgeFile));
56 if (!string.Equals(Convert.ToHexString(SHA256.HashData(bridgeBytes)), metadata.Sha256, StringComparison.OrdinalIgnoreCase))
57 throw new InvalidDataException("兼容组件 SHA-256 校验失败:" + payload);
58 using (var pe = new BinaryImage(bridgeBytes)) _ = pe.ExportRva("XfeInitialize");
59 cancel.ThrowIfCancellationRequested();
33 60 game = ValidateGame(game);
34 61 server = ClientProtocol.NormalizeServer(server);
35 62 EnsureStopped(game);
@@ -40,13 +67,6 @@ public sealed class ClientInstaller
40 67 if (!report.Compatible) throw new InvalidDataException(string.Join(Environment.NewLine, report.Problems));
41 68 progress?.Report(report.ExperimentalMultiAbilities ? $"此游戏版本可使用多技能实验绑定(需要兼容组件 {MinimumBridgeVersion} 或更新版本)。" : "此游戏版本不支持多技能实验绑定,保留常规单技能模式。");
42 69 cancel.ThrowIfCancellationRequested();
43 var metadata = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(payload, "payload.json")), ClientJson.Default.PayloadManifest) ?? throw new InvalidDataException("兼容组件清单为空。");
44 if (metadata.SchemaVersion != 1) throw new InvalidDataException("兼容组件清单版本不支持。");
45 RequireCurrentBridge(metadata.Version);
46 var bridgePath = Path.Combine(payload, BridgeFile);
47 var bridgeBytes = File.ReadAllBytes(bridgePath);
48 if (!string.Equals(Convert.ToHexString(SHA256.HashData(bridgeBytes)), metadata.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("兼容组件 SHA-256 校验失败。");
49 using (var pe = new BinaryImage(bridgeBytes)) _ = pe.ExportRva("XfeInitialize");
50 70 var previous = ReadState(game);
51 71 var changes = new Dictionary<string, byte[]?>();
52 72 var legacy = previous?.LegacyFiles.ToList() ?? [];
@@ -128,10 +148,12 @@ public sealed class ClientInstaller
128 148 if (!File.Exists(Path.Combine(path, "Warframe.x64.exe"))) throw new FileNotFoundException("目录中没有 Warframe.x64.exe。");
129 149 return path;
130 150 }
131 private static void RequireCurrentBridge(string? version)
151 private static void RequireCurrentBridge(string? version, string? payload = null)
132 152 {
133 153 if (!Version.TryParse(version, out var installed) || installed < Version.Parse(MinimumBridgeVersion))
134 throw new InvalidDataException($"兼容组件版本过旧({version ?? "未知"}),当前启动器需要 {MinimumBridgeVersion} 或更新版本。请在“安装与维护”中修复安装。");
154 throw new InvalidDataException(payload is null
155 ? $"兼容组件版本过旧({version ?? "未知"}),当前启动器需要 {MinimumBridgeVersion} 或更新版本。请在“安装与维护”中修复安装。"
156 : $"所选兼容组件目录版本过旧({version ?? "未知"}),当前启动器需要 {MinimumBridgeVersion} 或更新版本。目录:{payload}。请重新构建玩家端/安装器,或使用完整发行包中的 Bridge 目录。");
135 157 }
136 158 public static void EnsureStopped(string game)
137 159 {
Modified Client/XFEWarframe.Installer/XFEWarframe.Installer.csproj +1 -0
@@ -2,4 +2,5 @@
2 2 <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0-windows</TargetFramework><RuntimeIdentifier>win-x64</RuntimeIdentifier><PlatformTarget>x64</PlatformTarget></PropertyGroup>
3 3 <PropertyGroup><RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory><Platforms>x64</Platforms></PropertyGroup>
4 4 <ItemGroup><ProjectReference Include="../XFEWarframe.Client/XFEWarframe.Client.csproj" /></ItemGroup>
5 <Import Project="../BridgePayload.targets" />
5 6 </Project>
Modified Client/XFEWarframe.Player/Implements/Services/PlayerService.cs +2 -2
@@ -119,8 +119,8 @@ public sealed class PlayerService : GlobalServiceBase, IPlayerService
119 119 {
120 120 var game = SaveAndValidate();
121 121 var server = State.ServerUrl;
122 var payload = AppPath.Argument("payload") ?? ClientInstaller.FindPayload();
123 ShowReport(await Task.Run(() => installer.Install(game, server, payload, progress, cancel), cancel));
122 var explicitPayload = AppPath.Argument("payload");
123 ShowReport(await Task.Run(() => installer.Install(game, server, explicitPayload ?? ClientInstaller.FindPayload(), progress, cancel), cancel));
124 124 State.OperationStatus = "安装完成,可以返回首页开始游戏。";
125 125 });
126 126
Modified Client/XFEWarframe.Player/Utilities/Diagnostics/DesktopVerification.cs +11 -0
@@ -33,6 +33,16 @@ internal static partial class DesktopVerification
33 33 if (!App.MainWindow.ExtendsContentIntoTitleBar || App.MainWindow.Content is not AppShellPage)
34 34 throw new InvalidOperationException("Native window shell was not initialized.");
35 35 checks.Add("native-titlebar-and-app-shell");
36 var bundledVersion = await Task.Run(() =>
37 {
38 var payload = ClientInstaller.FindPayload();
39 var manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(payload, "payload.json")), ClientJson.Default.PayloadManifest)!;
40 var hash = Compatibility.Hash(Path.Combine(payload, ClientInstaller.BridgeFile));
41 if (manifest.Version != RuntimeControlServer.Version || !string.Equals(manifest.Sha256, hash, StringComparison.OrdinalIgnoreCase))
42 throw new InvalidDataException("The desktop application did not bundle the current native component with a matching checksum.");
43 return manifest.Version;
44 });
45 checks.Add($"bundled-native-component: {bundledVersion}, automatic discovery, current runtime version and matching SHA-256");
36 46 await App.Service.InitializeAsync();
37 47 var api = AppPath.Argument("verify-api") ?? throw new InvalidOperationException("Player verification requires an isolated --verify-api.");
38 48 if (!Uri.TryCreate(api, UriKind.Absolute, out var endpoint) || !endpoint.IsLoopback) throw new InvalidOperationException("UI verification only accepts a local test API.");
@@ -417,6 +427,7 @@ internal static partial class DesktopVerification
417 427 async () => { var current = (EquipmentPage)navigation.NavigationService.Frame!.Content; if (current.ViewModel.LoadCommand.ExecutionTask is Task load) await load; return (current, (EquipmentEditorView)current.FindName("EditorView")); },
418 428 () => navigation.NavigationService.GoBack(), liveInput);
419 429 checks.Add("exalted-equipment: host-to-Glory shared card board, Warframe/Glory install/swap/uninstall, raw-to-instance conversion, C save, independent B, back retains host");
430 checks.Add("image-loading: native WinUI PNG decode, cache miss/hit, resource icon, stale request protection, no wrong-thread dependency-property reads");
420 431 checks.Add("mod-search: original descriptions, Viral includes Cold/Toxin, matching suggestions, AND keywords, cancelled stale request cannot replace results");
421 432 navigation.NavigationService.GoBack();
422 433 if (navigation.NavigationService.Frame.Content is not EditorPage returned) throw new InvalidOperationException("Native Back did not return to the inventory list.");
Modified Client/XFEWarframe.Player/XFEWarframe.Player.csproj +1 -0
@@ -37,4 +37,5 @@
37 37 </ItemGroup>
38 38 </Target>
39 39 <ItemGroup><EmbeddedResource Include="../SharedUI/Localization/translations.json" LogicalName="XFEWarframe.UiTranslations.json" WithCulture="false" /></ItemGroup>
40 <Import Project="../BridgePayload.targets" />
40 41 </Project>
Modified Directory.Build.props +1 -0
Modified docs/DEBUGGING.md +2 -1
Modified docs/EXPERIMENTAL-ABILITIES.md +1 -1
Modified docs/INSTALLER.md +1 -1
Modified scripts/Build-Bridge.ps1 +73 -8
Modified scripts/Build.ps1 +1 -1
Modified scripts/Publish.ps1 +1 -1
Modified scripts/Start-Player.ps1 +0 -1