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