mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-20 13:51:28 +08:00
feat: implement plugin market installation service and associated infrastructure for desktop widgets and window management
This commit is contained in:
211
LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs
Normal file
211
LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Views;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopWidgetWindowChromeTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public void DirectRootBorderOwnsTheOnlyRoundedContourAndHasNoOuterShadow()
|
||||
{
|
||||
var componentRoot = new Border
|
||||
{
|
||||
BoxShadow = BoxShadows.Parse("0 8 24 #66000000")
|
||||
};
|
||||
var component = new UserControl
|
||||
{
|
||||
Content = componentRoot
|
||||
};
|
||||
|
||||
var window = new DesktopWidgetWindow(component, "placement", 18d);
|
||||
window.UpdateComponentLayout(200d, 120d);
|
||||
|
||||
var host = Assert.IsType<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
var editBorder = Assert.IsType<Border>(window.FindControl<Border>("EditModeBorder"));
|
||||
|
||||
Assert.Equal(new CornerRadius(18d), componentRoot.CornerRadius);
|
||||
Assert.True(componentRoot.ClipToBounds);
|
||||
Assert.Equal(default(BoxShadows), componentRoot.BoxShadow);
|
||||
Assert.Equal(default, host.CornerRadius);
|
||||
Assert.False(host.ClipToBounds);
|
||||
Assert.Equal(new CornerRadius(18d), editBorder.CornerRadius);
|
||||
Assert.Null(editBorder.Effect);
|
||||
|
||||
componentRoot.BoxShadow = BoxShadows.Parse("0 10 30 #88000000");
|
||||
componentRoot.CornerRadius = new CornerRadius(2d);
|
||||
componentRoot.ClipToBounds = false;
|
||||
|
||||
Assert.Equal(default(BoxShadows), componentRoot.BoxShadow);
|
||||
Assert.Equal(new CornerRadius(18d), componentRoot.CornerRadius);
|
||||
Assert.True(componentRoot.ClipToBounds);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void NonBorderRootUsesHostRoundedClip()
|
||||
{
|
||||
var component = new Grid();
|
||||
var window = new DesktopWidgetWindow(component, "placement", 14d);
|
||||
window.UpdateComponentLayout(180d, 100d);
|
||||
|
||||
var host = Assert.IsType<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
|
||||
Assert.Equal(new CornerRadius(14d), host.CornerRadius);
|
||||
Assert.True(host.ClipToBounds);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void TemplatedContentControlDoesNotTransferContourOwnershipToItsContent()
|
||||
{
|
||||
var contentBorder = new Border
|
||||
{
|
||||
BoxShadow = BoxShadows.Parse("0 4 12 #44000000")
|
||||
};
|
||||
var component = new ContentControl
|
||||
{
|
||||
Content = contentBorder
|
||||
};
|
||||
var window = new DesktopWidgetWindow(component, "placement", 16d);
|
||||
window.UpdateComponentLayout(180d, 100d);
|
||||
|
||||
var host = Assert.IsType<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
|
||||
Assert.Equal(new CornerRadius(16d), host.CornerRadius);
|
||||
Assert.True(host.ClipToBounds);
|
||||
Assert.NotEqual(default, contentBorder.BoxShadow);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void ReplacingUserControlRootTransfersContourOwnershipToTheNewBorder()
|
||||
{
|
||||
var firstRoot = new Border();
|
||||
var component = new UserControl { Content = firstRoot };
|
||||
var window = new DesktopWidgetWindow(component, "placement", 20d);
|
||||
|
||||
var replacementRoot = new Border
|
||||
{
|
||||
BoxShadow = BoxShadows.Parse("0 8 20 #66000000")
|
||||
};
|
||||
component.Content = replacementRoot;
|
||||
|
||||
Assert.Equal(new CornerRadius(20d), replacementRoot.CornerRadius);
|
||||
Assert.True(replacementRoot.ClipToBounds);
|
||||
Assert.Equal(default(BoxShadows), replacementRoot.BoxShadow);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void HiddenComponentRootDoesNotFallBackToAFullWindowInteractiveRegion()
|
||||
{
|
||||
var componentRoot = new Border { IsVisible = false };
|
||||
var component = new UserControl { Content = componentRoot };
|
||||
var window = new DesktopWidgetWindow(component, "placement", 18d);
|
||||
var method = typeof(DesktopWidgetWindow).GetMethod(
|
||||
"ResolveLiveInteractiveRegion",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var result = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) });
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void HiddenNonBorderComponentDoesNotUseTheVisibleHostAsItsInteractiveRegion()
|
||||
{
|
||||
var component = new Grid();
|
||||
var window = new DesktopWidgetWindow(component, "placement", 18d);
|
||||
window.UpdateComponentLayout(200d, 120d);
|
||||
var root = Assert.IsType<Grid>(window.FindControl<Grid>("RootGrid"));
|
||||
root.Measure(new Size(200d, 120d));
|
||||
root.Arrange(new Rect(0, 0, 200d, 120d));
|
||||
var method = typeof(DesktopWidgetWindow).GetMethod(
|
||||
"ResolveLiveInteractiveRegion",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var visibleResult = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) });
|
||||
component.IsVisible = false;
|
||||
var hiddenResult = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) });
|
||||
|
||||
Assert.NotNull(visibleResult);
|
||||
Assert.Null(hiddenResult);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void ResizeAdornerMatchesComponentBoundsAndKeepsEveryHandleInside()
|
||||
{
|
||||
var component = new Grid();
|
||||
var window = new DesktopWidgetWindow(component, "placement", 12d);
|
||||
window.UpdateComponentLayout(200d, 120d);
|
||||
|
||||
var host = Assert.IsType<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
var root = Assert.IsType<Grid>(window.FindControl<Grid>("RootGrid"));
|
||||
var adorner = Assert.Single(root.Children.OfType<DesktopWidgetResizeAdorner>());
|
||||
|
||||
adorner.Show();
|
||||
host.Measure(new Size(200d, 120d));
|
||||
host.Arrange(new Rect(0d, 0d, 200d, 120d));
|
||||
adorner.Measure(new Size(200d, 120d));
|
||||
adorner.Arrange(new Rect(0d, 0d, 200d, 120d));
|
||||
|
||||
Assert.Equal(host.Bounds.Size, adorner.Bounds.Size);
|
||||
Assert.Equal(new Size(200d, 120d), adorner.Bounds.Size);
|
||||
|
||||
foreach (var handle in adorner.Children.OfType<DesktopWidgetResizeHandle>())
|
||||
{
|
||||
var left = Canvas.GetLeft(handle);
|
||||
var top = Canvas.GetTop(handle);
|
||||
|
||||
Assert.InRange(left, 0d, adorner.Bounds.Width - handle.Width);
|
||||
Assert.InRange(top, 0d, adorner.Bounds.Height - handle.Height);
|
||||
Assert.True(left + handle.Width <= adorner.Bounds.Width);
|
||||
Assert.True(top + handle.Height <= adorner.Bounds.Height);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1d)]
|
||||
[InlineData(1.25d)]
|
||||
[InlineData(1.5d)]
|
||||
[InlineData(2d)]
|
||||
public void ResizeMathKeepsPhysicalRightEdgeUnderPointerAcrossDpi(double currentScaling)
|
||||
{
|
||||
var result = DesktopWidgetWindow.CalculateResizedBounds(
|
||||
ResizeHandlePosition.Right,
|
||||
new Point(20d, 0d),
|
||||
new Size(200d, 120d),
|
||||
new PixelPoint(-500, 100),
|
||||
currentScaling);
|
||||
|
||||
Assert.Equal(220d, result.width * currentScaling, 6);
|
||||
Assert.Equal(120d, result.height * currentScaling, 6);
|
||||
Assert.Equal(-500d, result.x);
|
||||
Assert.Equal(100d, result.y);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1d)]
|
||||
[InlineData(1.25d)]
|
||||
[InlineData(1.5d)]
|
||||
[InlineData(2d)]
|
||||
public void ResizeMathKeepsOppositeEdgeFixedForLeftResizeAcrossDpi(double currentScaling)
|
||||
{
|
||||
var result = DesktopWidgetWindow.CalculateResizedBounds(
|
||||
ResizeHandlePosition.Left,
|
||||
new Point(20d, 0d),
|
||||
new Size(200d, 120d),
|
||||
new PixelPoint(-500, 100),
|
||||
currentScaling);
|
||||
|
||||
var physicalWidth = result.width * currentScaling;
|
||||
Assert.Equal(180d, physicalWidth, 6);
|
||||
Assert.Equal(-480d, result.x);
|
||||
Assert.Equal(-300d, result.x + physicalWidth, 6);
|
||||
Assert.Equal(120d, result.height * currentScaling, 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class FusedDesktopComponentLibraryWindowShellTests
|
||||
{
|
||||
[Fact]
|
||||
public void Window_UsesTransparentFullClientArea()
|
||||
{
|
||||
var xaml = ReadRepositoryFile(
|
||||
"LanMountainDesktop",
|
||||
"Views",
|
||||
"FusedDesktopComponentLibraryWindow.axaml");
|
||||
var window = ExtractElementStart(xaml, "<Window ");
|
||||
|
||||
Assert.Contains("WindowDecorations=\"None\"", window);
|
||||
Assert.Contains("Background=\"Transparent\"", window);
|
||||
Assert.Contains("TransparencyLevelHint=\"Transparent\"", window);
|
||||
Assert.Contains("ExtendClientAreaToDecorationsHint=\"True\"", window);
|
||||
Assert.Contains("ExtendClientAreaTitleBarHeightHint=\"-1\"", window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PanelShell_FillsClientAreaWithoutOuterShadowGutter()
|
||||
{
|
||||
var xaml = ReadRepositoryFile(
|
||||
"LanMountainDesktop",
|
||||
"Views",
|
||||
"FusedDesktopComponentLibraryWindow.axaml");
|
||||
var panelShell = ExtractElementStart(xaml, "<Border x:Name=\"PanelShell\"");
|
||||
|
||||
Assert.Contains("Classes=\"surface-translucent-strong\"", panelShell);
|
||||
Assert.Contains("HorizontalAlignment=\"Stretch\"", panelShell);
|
||||
Assert.Contains("VerticalAlignment=\"Stretch\"", panelShell);
|
||||
Assert.Contains("Margin=\"0\"", panelShell);
|
||||
Assert.Contains("BoxShadow=\"none\"", panelShell);
|
||||
Assert.Contains("CornerRadius=\"{DynamicResource DesignCornerRadiusLg}\"", panelShell);
|
||||
Assert.Contains("ClipToBounds=\"True\"", panelShell);
|
||||
Assert.DoesNotContain("Margin=\"10\"", panelShell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_DisablesNativeWindowsCornerAndBorderTreatment()
|
||||
{
|
||||
var codeBehind = ReadRepositoryFile(
|
||||
"LanMountainDesktop",
|
||||
"Views",
|
||||
"FusedDesktopComponentLibraryWindow.axaml.cs");
|
||||
|
||||
Assert.Contains("Win32Properties.SetWindowCornerPreference(", codeBehind);
|
||||
Assert.Contains("Win32Properties.WindowCornerPreference.DoNotRound", codeBehind);
|
||||
Assert.Contains("DwmWindowAttributeBorderColor = 34", codeBehind);
|
||||
Assert.Contains("DwmColorNone = 0xFFFFFFFE", codeBehind);
|
||||
}
|
||||
|
||||
private static string ExtractElementStart(string source, string startToken)
|
||||
{
|
||||
var start = source.IndexOf(startToken, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Could not find '{startToken}'.");
|
||||
|
||||
var end = source.IndexOf('>', start);
|
||||
Assert.True(end > start, $"Could not find end of '{startToken}'.");
|
||||
|
||||
return source.Substring(start, end - start + 1);
|
||||
}
|
||||
|
||||
private static string ReadRepositoryFile(params string[] segments)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||
}
|
||||
}
|
||||
57
LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs
Normal file
57
LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class FusedDesktopScreenTopologyTests
|
||||
{
|
||||
[Fact]
|
||||
public void RemovedMonitorPositionMovesToNearestRemainingWorkingArea()
|
||||
{
|
||||
var screens = new[]
|
||||
{
|
||||
new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 1920, 1040), 1d)
|
||||
};
|
||||
|
||||
var result = FusedDesktopManagerService.CoerceToValidWorkingArea(
|
||||
new PixelPoint(-2600, 200),
|
||||
new Size(300, 180),
|
||||
screens);
|
||||
|
||||
Assert.Equal(new PixelPoint(0, 200), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighDpiWorkingAreaKeepsEntireWidgetVisible()
|
||||
{
|
||||
var screens = new[]
|
||||
{
|
||||
new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 1920, 1040), 2d)
|
||||
};
|
||||
|
||||
var result = FusedDesktopManagerService.CoerceToValidWorkingArea(
|
||||
new PixelPoint(1800, 980),
|
||||
new Size(200, 120),
|
||||
screens);
|
||||
|
||||
Assert.Equal(new PixelPoint(1520, 800), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeCoordinateMonitorUsesItsOwnWorkAreaAndScaling()
|
||||
{
|
||||
var screens = new[]
|
||||
{
|
||||
new FusedDesktopScreenWorkArea(new PixelRect(-1920, 0, 1920, 1080), 1d),
|
||||
new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 2560, 1400), 1.5d)
|
||||
};
|
||||
|
||||
var result = FusedDesktopManagerService.CoerceToValidWorkingArea(
|
||||
new PixelPoint(-100, 1000),
|
||||
new Size(200, 200),
|
||||
screens);
|
||||
|
||||
Assert.Equal(new PixelPoint(-200, 880), result);
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs
Normal file
79
LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginMarketCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_RejectsPluginFromPreviousApiMajor()
|
||||
{
|
||||
var error = AirAppMarketCompatibility.Validate(
|
||||
CreatePlugin(apiVersion: "4.0.0"),
|
||||
new Version(0, 8, 8),
|
||||
"5.0.0");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("incompatible API version 4.0.0", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ChecksApiMajorEvenWhenHostProductVersionIsUnavailable()
|
||||
{
|
||||
var error = AirAppMarketCompatibility.Validate(
|
||||
CreatePlugin(apiVersion: "4.0.0"),
|
||||
hostVersion: null,
|
||||
hostApiVersion: "5.0.0");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("Host API version is 5.0.0", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsPluginThatRequiresNewerHost()
|
||||
{
|
||||
var error = AirAppMarketCompatibility.Validate(
|
||||
CreatePlugin(apiVersion: "5.0.0", minHostVersion: "0.9.0"),
|
||||
new Version(0, 8, 8),
|
||||
"5.0.0");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("requires host version 0.9.0 or newer", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsCompatiblePlugin()
|
||||
{
|
||||
var error = AirAppMarketCompatibility.Validate(
|
||||
CreatePlugin(apiVersion: "5.2.0", minHostVersion: "0.8.0"),
|
||||
new Version(0, 8, 8),
|
||||
"5.0.0");
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
private static AirAppMarketPluginEntry CreatePlugin(
|
||||
string apiVersion,
|
||||
string minHostVersion = "0.0.1") =>
|
||||
new()
|
||||
{
|
||||
PluginId = "Example.Plugin",
|
||||
Id = "Example.Plugin",
|
||||
Name = "Example Plugin",
|
||||
Description = "Compatibility test plugin.",
|
||||
Author = "LanMountainDesktop",
|
||||
Version = "1.0.0",
|
||||
ApiVersion = apiVersion,
|
||||
MinHostVersion = minHostVersion,
|
||||
RepositoryUrl = "https://github.com/example/example-plugin",
|
||||
PackageSources =
|
||||
[
|
||||
new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = "workspaceLocal",
|
||||
Url = "workspace://Example.Plugin/Example.Plugin.1.0.0.laapp",
|
||||
SourceKind = PluginPackageSourceKind.WorkspaceLocal
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,9 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class PluginMarketIndexDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_WithNestedV2Entry_MapsDisplayFieldsAndWorkspacePath()
|
||||
public void Load_WithFlatV3Entry_MapsDisplayFieldsAndWorkspacePath()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(CreateNestedIndexJson(), "test-index.json");
|
||||
var document = AirAppMarketIndexDocument.Load(CreateFlatIndexJson(), "test-index.json");
|
||||
var plugin = Assert.Single(document.Plugins);
|
||||
var source = Assert.Single(plugin.PackageSources);
|
||||
|
||||
@@ -25,7 +25,66 @@ public sealed class PluginMarketIndexDocumentTests
|
||||
Assert.Equal(PluginPackageSourceKind.WorkspaceLocal, source.SourceKind);
|
||||
}
|
||||
|
||||
private static string CreateNestedIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
[Fact]
|
||||
public void Load_WithLegacyNestedV2Entry_RejectsUnsupportedSchema()
|
||||
{
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
AirAppMarketIndexDocument.Load(CreateNestedV2IndexJson(), "legacy-index.json"));
|
||||
|
||||
Assert.Contains("schemaVersion '2.0.0'", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("only supports '3.0.0'", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string CreateFlatIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"schemaVersion": "3.0.0",
|
||||
"sourceId": "official",
|
||||
"sourceName": "LanAirApp",
|
||||
"generatedAt": "2026-04-29T00:00:00Z",
|
||||
"contracts": [],
|
||||
"plugins": [
|
||||
{
|
||||
"pluginId": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "SDK v5 sample plugin.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "0.4.0",
|
||||
"apiVersion": "5.0.0",
|
||||
"minHostVersion": "0.0.1",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll",
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/wwiinnddyy/{{repositoryName}}/main/README.md",
|
||||
"projectUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"releaseTag": "v0.4.0",
|
||||
"releaseAssetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"packageSizeBytes": 1024,
|
||||
"publishedAt": "2026-04-29T00:00:00Z",
|
||||
"updatedAt": "2026-04-29T00:00:00Z",
|
||||
"releaseNotes": "Reference plugin for SDK v5 validation.",
|
||||
"tags": [ "official", "sdk" ],
|
||||
"sharedContracts": [],
|
||||
"desktopComponents": [ "LanMountainDesktop.SamplePlugin.StatusClock" ],
|
||||
"settingsSections": [ "status" ],
|
||||
"exports": [],
|
||||
"messageTypes": [],
|
||||
"packageSources": [
|
||||
{
|
||||
"kind": "workspaceLocal",
|
||||
"url": "workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string CreateNestedV2IndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
|
||||
264
LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs
Normal file
264
LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WindowPassthroughServiceTests
|
||||
{
|
||||
private const uint WsChild = 0x40000000U;
|
||||
private const uint WsPopup = 0x80000000U;
|
||||
private const uint WsCaption = 0x00C00000U;
|
||||
private const uint WsThickFrame = 0x00040000U;
|
||||
private const uint WsMinimizeBox = 0x00020000U;
|
||||
private const uint WsMaximizeBox = 0x00010000U;
|
||||
private const uint WsSysMenu = 0x00080000U;
|
||||
private const uint WsVisible = 0x10000000U;
|
||||
private const uint WsExToolWindow = 0x00000080U;
|
||||
private const uint WsExAppWindow = 0x00040000U;
|
||||
private const uint WsExNoActivate = 0x08000000U;
|
||||
private const uint WsExNoRedirectionBitmap = 0x00200000U;
|
||||
private const uint WsExTopmost = 0x00000008U;
|
||||
|
||||
[Fact]
|
||||
public void DesktopChildStylePolicy_PreservesAvaloniaCompositionBits()
|
||||
{
|
||||
var style = WsPopup | WsCaption | WsThickFrame | WsMinimizeBox |
|
||||
WsMaximizeBox | WsSysMenu | WsVisible;
|
||||
var exStyle = WsExNoRedirectionBitmap | WsExTopmost | WsExAppWindow;
|
||||
|
||||
var result = Invoke<(uint Style, uint ExStyle)>(
|
||||
"CreateDesktopChildStyles",
|
||||
style,
|
||||
exStyle);
|
||||
|
||||
Assert.NotEqual(0U, result.Style & WsChild);
|
||||
Assert.NotEqual(0U, result.Style & WsVisible);
|
||||
Assert.Equal(0U, result.Style & WsPopup);
|
||||
Assert.Equal(0U, result.Style & WsCaption);
|
||||
Assert.Equal(0U, result.Style & WsThickFrame);
|
||||
Assert.Equal(0U, result.Style & WsMinimizeBox);
|
||||
Assert.Equal(0U, result.Style & WsMaximizeBox);
|
||||
Assert.Equal(0U, result.Style & WsSysMenu);
|
||||
|
||||
Assert.NotEqual(0U, result.ExStyle & WsExNoRedirectionBitmap);
|
||||
Assert.NotEqual(0U, result.ExStyle & WsExTopmost);
|
||||
Assert.NotEqual(0U, result.ExStyle & WsExToolWindow);
|
||||
Assert.NotEqual(0U, result.ExStyle & WsExNoActivate);
|
||||
Assert.Equal(0U, result.ExStyle & WsExAppWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeIntegration_UsesAvaloniaCallbacksWithoutManualWndProcSubclassing()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs");
|
||||
|
||||
Assert.Contains("Win32Properties.AddWindowStylesCallback", source);
|
||||
Assert.Contains("Win32Properties.AddWndProcHookCallback", source);
|
||||
Assert.Contains("Win32Properties.RemoveWindowStylesCallback", source);
|
||||
Assert.Contains("Win32Properties.RemoveWndProcHookCallback", source);
|
||||
Assert.DoesNotContain("WS_EX_LAYERED", source);
|
||||
Assert.DoesNotContain("GWLP_WNDPROC", source);
|
||||
Assert.DoesNotContain("_originalWndProcs", source);
|
||||
Assert.Contains("SWP_FRAMECHANGED", source);
|
||||
Assert.Contains("DwmSetWindowAttribute", source);
|
||||
Assert.Contains("WindowCornerPreference.DoNotRound", source);
|
||||
Assert.Contains("NeedsNativeRepair", source);
|
||||
Assert.Contains("retrying incomplete native rollback", source);
|
||||
Assert.Contains("if (!restored)", source);
|
||||
Assert.Contains("SWP_HIDEWINDOW", source);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(96d, 200d, 120d, 200d, 120d)]
|
||||
[InlineData(120d, 250d, 150d, 200d, 120d)]
|
||||
[InlineData(144d, 300d, 180d, 200d, 120d)]
|
||||
[InlineData(192d, 400d, 240d, 200d, 120d)]
|
||||
public void HitTestCoordinates_ScalePhysicalPixelsAcrossSupportedDpiRanges(
|
||||
double dpi,
|
||||
double physicalX,
|
||||
double physicalY,
|
||||
double expectedX,
|
||||
double expectedY)
|
||||
{
|
||||
var result = Invoke<Point>(
|
||||
"ConvertPhysicalClientPointToDip",
|
||||
new Point(physicalX, physicalY),
|
||||
dpi / 96d);
|
||||
|
||||
Assert.Equal(new Point(expectedX, expectedY), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTesting_ExcludesRoundedTransparentCorners()
|
||||
{
|
||||
var region = new WindowInteractiveRegion(new Rect(0, 0, 100, 80), 20);
|
||||
|
||||
Assert.False(IsInside(region, new Point(1, 1)));
|
||||
Assert.False(IsInside(region, new Point(99, 1)));
|
||||
Assert.False(IsInside(region, new Point(1, 79)));
|
||||
Assert.False(IsInside(region, new Point(99, 79)));
|
||||
Assert.True(IsInside(region, new Point(50, 1)));
|
||||
Assert.True(IsInside(region, new Point(1, 40)));
|
||||
Assert.True(IsInside(region, new Point(50, 40)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTesting_SupportsNegativeDesktopCoordinatesAndRectangularEditRegions()
|
||||
{
|
||||
var rounded = new WindowInteractiveRegion(new Rect(-100, -50, 100, 80), 20);
|
||||
var editRegion = new WindowInteractiveRegion(new Rect(-100, -50, 100, 80), 0);
|
||||
|
||||
Assert.False(IsInside(rounded, new Point(-99, -49)));
|
||||
Assert.True(IsInside(rounded, new Point(-50, -49)));
|
||||
Assert.True(IsInside(rounded, new Point(-50, -10)));
|
||||
Assert.True(IsInside(editRegion, new Point(-99, -49)));
|
||||
Assert.False(IsInside(editRegion, new Point(1, -10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTesting_UsesInverseRegionTransformForScaledAndTranslatedRoots()
|
||||
{
|
||||
var clientToLocal = new Matrix(
|
||||
0.5, 0,
|
||||
0, 0.5,
|
||||
-5, -10);
|
||||
var region = new WindowInteractiveRegion(
|
||||
new Rect(0, 0, 100, 80),
|
||||
20,
|
||||
clientToLocal);
|
||||
|
||||
Assert.False(IsInside(region, new Point(12, 22)));
|
||||
Assert.True(IsInside(region, new Point(110, 100)));
|
||||
Assert.False(IsInside(region, new Point(212, 100)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTesting_UsesLocalRoundedGeometryForRotatedRoots()
|
||||
{
|
||||
var clientToLocal = new Matrix(
|
||||
0, -1,
|
||||
1, 0,
|
||||
-50, 200);
|
||||
var region = new WindowInteractiveRegion(
|
||||
new Rect(0, 0, 100, 80),
|
||||
20,
|
||||
clientToLocal);
|
||||
|
||||
Assert.True(IsInside(region, new Point(160, 100)));
|
||||
Assert.False(IsInside(region, new Point(121, 51)));
|
||||
Assert.False(IsInside(region, new Point(210, 100)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreCoordinatePolicy_UsesParentClientCoordinatesOnlyForOriginalChildWindows()
|
||||
{
|
||||
Assert.True(Invoke<bool>("OriginalWindowUsesParentClientCoordinates", WsChild));
|
||||
Assert.False(Invoke<bool>("OriginalWindowUsesParentClientCoordinates", WsPopup));
|
||||
Assert.False(Invoke<bool>("OriginalWindowUsesParentClientCoordinates", 0U));
|
||||
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs");
|
||||
Assert.Contains("GWLP_HWNDPARENT", source);
|
||||
Assert.Contains("restore the owner through GWLP_HWNDPARENT", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonitorPolicy_HonorsPerWindowRepairBackoffAndHostChanges()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 14, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.False(Invoke<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
false,
|
||||
now,
|
||||
now.AddSeconds(30)));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
false,
|
||||
now,
|
||||
now.AddSeconds(-1)));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
true,
|
||||
now,
|
||||
now.AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonitorPolicy_DoesNotRemountSafeFallbackUntilHostActuallyChanges()
|
||||
{
|
||||
var currentHost = new IntPtr(42);
|
||||
|
||||
Assert.False(Invoke<bool>(
|
||||
"ShouldAttemptDesktopAttachment",
|
||||
false,
|
||||
IntPtr.Zero,
|
||||
currentHost,
|
||||
false));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptDesktopAttachment",
|
||||
true,
|
||||
IntPtr.Zero,
|
||||
currentHost,
|
||||
false));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptDesktopAttachment",
|
||||
false,
|
||||
currentHost,
|
||||
currentHost,
|
||||
true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTestCoordinates_AreResolvedFromTheLiveWindowState()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs");
|
||||
|
||||
Assert.Contains("ScreenToClient(hWnd, ref screenPoint)", source);
|
||||
Assert.Contains("GetDpiForWindow(handle)", source);
|
||||
Assert.DoesNotContain("_windowScreenOrigins", source);
|
||||
Assert.DoesNotContain("_windowDpiScales", source);
|
||||
Assert.DoesNotContain("GetDpiForMonitor", source);
|
||||
}
|
||||
|
||||
private static bool IsInside(WindowInteractiveRegion region, Point point)
|
||||
{
|
||||
return Invoke<bool>("IsPointInsideRegion", region, point);
|
||||
}
|
||||
|
||||
private static T Invoke<T>(string methodName, params object[] arguments)
|
||||
{
|
||||
var serviceType = typeof(IWindowBottomMostService).Assembly.GetType(
|
||||
"LanMountainDesktop.Services.WindowsWindowBottomMostService",
|
||||
throwOnError: true)!;
|
||||
var method = serviceType.GetMethod(
|
||||
methodName,
|
||||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
|
||||
Assert.NotNull(method);
|
||||
return (T)method.Invoke(null, arguments)!;
|
||||
}
|
||||
|
||||
private static string ReadRepositoryFile(params string[] segments)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user