diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 43de3ddefd..1ae5315758 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,16 @@ When running code review, please refer to the following documents if they are relevant to the changed parts of the codebase: - `native/Avalonia.Native/**` -> `native/Avalonia.Native/README.md` +- `src/Avalonia.Wayland/**` -> `src/Avalonia.Wayland/README.md` + +## SBOM (EU Cyber Resilience Act) + +We generate a CycloneDX SBOM per published NuGet package (`nukebuild/SbomGenerator.cs`, `CreateSbom` target) so that every shipped package carries an accurate bill of materials, as required for EU CRA compliance. When reviewing, flag the following: + +- **New shipped package:** if a change adds a new NuGet package that is published to end users (a new packable project, or a new final package in `nukebuild/numerge.json`), it must be covered by SBOM generation. Confirm `CreateSbom` produces an SBOM for it, and call it out if the package would ship without one. +- **New kinds of shipped dependencies:** if a change alters what dependencies are delivered with a component in a way the existing scan doesn't already understand, `nukebuild/SbomGenerator.cs` must be updated so those dependencies appear in the SBOM. In particular: + - Adding npm/JS dependencies to a component that was previously .NET-only (e.g. a new `webapp` bundled into a package), or changing how existing bundled JS is built. + - Bundling third-party binaries directly into a package (copy-local/ILRepack/embedded assemblies) rather than referencing them as normal NuGet dependencies. + - New Numerge merge groups, or any other mechanism that folds a project's shipped output into a package other than via ordinary `PackageReference`/`ProjectReference`. + + The scope is what is *delivered* with the package (BSI TR-03183-2 "scope of delivery"), not build/test-only tooling. Purely development-time dependencies that are never shipped do not need to be in the SBOM. diff --git a/.gitignore b/.gitignore index 22077f6402..e2e3a8ef73 100644 --- a/.gitignore +++ b/.gitignore @@ -227,3 +227,5 @@ src/Browser/Avalonia.Browser/staticwebassets /.claude/settings.local.json /planning BenchmarkDotNet.Artifacts.* +.tokensave +/CLAUDE.md diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index b5f565a399..9b765c53ca 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -33,6 +33,7 @@ "CompileNative", "CreateIntermediateNugetPackages", "CreateNugetPackages", + "CreateSbom", "DownloadApiBaselinePackages", "GenerateCppHeaders", "GenerateUnicodeData", diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 3edf421eb4..2d66481319 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -34,6 +34,7 @@ "src\\Avalonia.Themes.Fluent\\Avalonia.Themes.Fluent.csproj", "src\\Avalonia.Themes.Simple\\Avalonia.Themes.Simple.csproj", "src\\Avalonia.Vulkan\\Avalonia.Vulkan.csproj", + "src\\Avalonia.Wayland\\Avalonia.Wayland.csproj", "src\\Avalonia.X11\\Avalonia.X11.csproj", "src\\HarfBuzz\\Avalonia.HarfBuzz\\Avalonia.HarfBuzz.csproj", "src\\Headless\\Avalonia.Headless.Vnc\\Avalonia.Headless.Vnc.csproj", @@ -72,6 +73,7 @@ "tests\\Avalonia.Skia.RenderTests\\Avalonia.Skia.RenderTests.csproj", "tests\\Avalonia.Skia.UnitTests\\Avalonia.Skia.UnitTests.csproj", "tests\\Avalonia.UnitTests\\Avalonia.UnitTests.csproj", + "tests\\Avalonia.Wayland.UnitTests\\Avalonia.Wayland.UnitTests.csproj", "tests\\TestFiles\\BuildTasks\\PInvoke\\PInvoke.csproj" ] } diff --git a/Avalonia.slnx b/Avalonia.slnx index b86ca527e1..a9d7e317d9 100644 --- a/Avalonia.slnx +++ b/Avalonia.slnx @@ -22,6 +22,7 @@ + @@ -65,6 +66,10 @@ + + + + @@ -84,6 +89,10 @@ + + + + @@ -149,6 +158,7 @@ + @@ -180,6 +190,7 @@ + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8351fc2ea6..de3c6e4782 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,8 @@ PRs are always welcomed from everyone. Following this guide will help us get your PR reviewed and merged as quickly as possible. +If you're an AI agent or a human using some AI assistance to open a PR, ensure you have read the [AI Guidelines](#ai-guidelines) section below. + For this guide we're going to split PRs into two types: bug fixes and features; the requirements for each are slightly different. ## Bug Fixes @@ -27,8 +29,6 @@ In this way the reviewer can check out the commit with the failing test and conf Features should always include unit tests or integration tests where possible. -> One exception to this is features related to DevTools which has no tests currently - Features that introduce new controls should consider the following: - Ideally the control should be exposed to the operating system's automation/accessibility APIs by writing an `AutomationPeer` @@ -85,6 +85,21 @@ Render tests should describe what the produced image is: void Rectangle_2px_Stroke_Filled() ``` +## AI Guidelines + +Avalonia accepts AI-assisted contributions. +However, if you're an autonomous AI agent, please stop right there. We want a human to read and respond to the various review comments. + +When opening an AI-authored or AI-assisted PR, please make sure that: +- As a human, you have fully read and completely understood all the changes your AI agent made. +- As a human, you have read the PR description your AI agent made. If you've only skimmed through it because it's way too long considering the relative simplicity of the associated fix, it probably needs changes. Please respect the maintainers' time, who will have to read it. +- Code comments are succinct and straight to the point. While commenting code is necessary, LLMs tend to be overly verbose, describing the behavior of the code preceding the change. Explaining what the current code does when it isn't obvious and referring to existing issues are fine. A ten-line comment about why the previous code was wrong isn't. Again, be succinct. +- In the same way, keep the PR description short and to the point, especially if the modification is simple. Do NOT re-explain every single line of change in plain English; the maintainers can read code. Don't repeat the same things three times with some slight rephrasing. +- Format the PR description properly: don't add arbitrary line breaks everywhere. We aren't in a terminal. + + +AI-assisted pull requests that don't follow these guidelines will be closed. + ## Code of Conduct This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. diff --git a/Directory.Build.props b/Directory.Build.props index 400198daf9..931a386d31 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,8 @@ 14.0 $(AvnTreatWarningsAsErrors) true + + $(NoWarn);NU1507 true true true diff --git a/Directory.Packages.props b/Directory.Packages.props index f4e677d631..983d369fab 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,12 +2,11 @@ true - - + @@ -23,23 +22,27 @@ - - - + + + + + - + + + @@ -62,17 +65,18 @@ + + - - + \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 7b30c0cdf8..2c1637fd04 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,5 @@ # Security Policy +Avalonia is free and open-source software published by AvaloniaUI OÜ. The framework is made publicly available at no charge and is not placed on the market within the meaning of the EU Cyber Resilience Act (Regulation (EU) 2024/2847). AvaloniaUI OÜ acts as the project's open-source software steward and maintains this cybersecurity policy in that capacity, in line with Article 24 of that Regulation. ## Reporting a Vulnerability @@ -21,5 +22,25 @@ https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/ ### Reporting via Email Alternatively, you may report security vulnerabilities by emailing security@avaloniaui.net. -### Misc +### What to Expect + +- We aim to acknowledge your report within **2 business days**. +- We aim to confirm whether we consider it a vulnerability and to share a remediation timeline within **10 business days**. +- We will keep you informed of progress and coordinate the disclosure date with you. +- We will credit you in the published advisory unless you ask us not to. + +These timescales are the targets we work to for the open-source project; they are not contractual commitments. Organisations that require contractually binding response and remediation times can obtain them under a commercial agreement. + Please note that Avalonia does not operate a bug bounty programme. + +## Versions Receiving Security Fixes +| Version | Security fixes | +|---|---| +| 11.x and older | Not provided | +| 12.x (current stable) | Provided | + +Security fixes are delivered at the head of the current stable series. If a vulnerability affects earlier 12.x releases, we fix it in a new release of the latest version, and the remediation path is to upgrade to that release. Security fixes for the current series are published openly and free of charge. Access to Avalonia's open-source releases, updates and security fixes is never conditional on payment. + +## Scope + +This policy covers the Avalonia framework packages published from this repository (`Avalonia` and the `Avalonia.*` platform and integration packages). diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index fd711d37c0..1dda132a59 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -19,12 +19,60 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@,Avalonia.Size@) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@) baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean) + baseline/Avalonia/lib/net10.0/Avalonia.OpenGL.dll + current/Avalonia/lib/net10.0/Avalonia.OpenGL.dll + CP0002 M:Avalonia.Input.PointerEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent,System.Object,Avalonia.Input.IPointer,Avalonia.Visual,Avalonia.Point,System.UInt64,Avalonia.Input.PointerPointProperties,Avalonia.Input.KeyModifiers,System.Lazy{System.Collections.Generic.IReadOnlyList{Avalonia.Input.Raw.RawPointerPoint}}) @@ -43,10 +91,178 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@,Avalonia.Size@) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@) baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll - \ No newline at end of file + + CP0002 + M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean) + baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + + + CP0006 + E:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguageChanged + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + P:Avalonia.Media.IImageBrushSource.Bitmap + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + P:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguage + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl.SetWindowCornerPreference(Avalonia.Controls.Win32Properties.WindowCornerPreference) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0006 + M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0006 + M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant}) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0006 + E:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguageChanged + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + P:Avalonia.Media.IImageBrushSource.Bitmap + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + P:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguage + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0006 + M:Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl.SetWindowCornerPreference(Avalonia.Controls.Win32Properties.WindowCornerPreference) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0006 + M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0006 + M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant}) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 06da4bcd2b..b61baece36 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -132,6 +132,12 @@ jobs: artifactName: 'NuGetOSX' condition: succeeded() + - task: PublishBuildArtifacts@1 + inputs: + pathtoPublish: '$(Build.SourcesDirectory)/artifacts/sbom' + artifactName: 'SBOMOSX' + condition: succeeded() + - job: Windows pool: vmImage: 'windows-2025' @@ -187,3 +193,9 @@ jobs: artifactName: 'Samples' condition: succeeded() + - task: PublishBuildArtifacts@1 + inputs: + pathtoPublish: '$(Build.SourcesDirectory)/artifacts/sbom' + artifactName: 'SBOM' + condition: succeeded() + diff --git a/build/SampleApp.props b/build/SampleApp.props index 6816a38cbe..e8a9b9166e 100644 --- a/build/SampleApp.props +++ b/build/SampleApp.props @@ -14,7 +14,7 @@ - + diff --git a/build/SharedVersion.props b/build/SharedVersion.props index cff9a24f54..2a4e07c3d2 100644 --- a/build/SharedVersion.props +++ b/build/SharedVersion.props @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> Avalonia - 12.1.999 + 12.2.999 Avalonia Team Copyright 2013-$([System.DateTime]::Now.ToString(`yyyy`)) © The AvaloniaUI Project https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link diff --git a/build/TargetFrameworks.props b/build/TargetFrameworks.props index 96541e7ecf..a380009e23 100644 --- a/build/TargetFrameworks.props +++ b/build/TargetFrameworks.props @@ -8,6 +8,7 @@ $(AvsCurrentTargetFramework)-ios26.0 $(AvsCurrentTargetFramework)-tvos26.0 $(AvsCurrentTargetFramework)-browser + $(AvsCurrentTargetFramework)-windows10.0.19041.0 net8.0 diff --git a/build/TrimmingEnable.props b/build/TrimmingEnable.props index cf84ae67b4..da921eeffe 100644 --- a/build/TrimmingEnable.props +++ b/build/TrimmingEnable.props @@ -1,10 +1,10 @@ - false - true - false - true + false + true + false + true true $(TreatWarningsAsErrors) diff --git a/dirs.proj b/dirs.proj index 20df84439e..43c2518f78 100644 --- a/dirs.proj +++ b/dirs.proj @@ -14,12 +14,18 @@ + + + + + + diff --git a/external/XamlX b/external/XamlX index 86f5a26fad..d7e37ca63d 160000 --- a/external/XamlX +++ b/external/XamlX @@ -1 +1 @@ -Subproject commit 86f5a26fadb745e95169c3c805ebd9fd7c9b44d2 +Subproject commit d7e37ca63dc9b13cdc95ca165938d4904fa0eddf diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h index 4e5116ee71..dd10ae28b4 100644 --- a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h +++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h @@ -38,7 +38,9 @@ public: virtual void SetCursorRect (AvnRect rect) override; - virtual void SetSurroundingText (char* text, int anchorOffset, int cursorOffset) override; + virtual void SetSurroundingText (char* text, int start, int end) override; + + virtual void SetSelectionInSurroundingText (int start, int end) override; public: ComPtr Client; diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm index 8c3ae080fa..a08f3886c8 100644 --- a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm +++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm @@ -29,13 +29,21 @@ HRESULT AvnTextInputMethod::SetClient(IAvnTextInputMethodClient *client) { } void AvnTextInputMethod::Reset() { + [_inputMethodDelegate resetInputMethod]; } -void AvnTextInputMethod::SetSurroundingText(char* text, int anchorOffset, int cursorOffset) { - [_inputMethodDelegate setText:[NSString stringWithUTF8String:text]]; - [_inputMethodDelegate setSelection: anchorOffset : cursorOffset]; +void AvnTextInputMethod::SetSurroundingText(char* text, int start, int end) { + // stringWithUTF8String: throws on a null pointer and returns nil for invalid UTF-8. + NSString* surroundingText = text != nullptr ? [NSString stringWithUTF8String:text] : nil; + + [_inputMethodDelegate setText:surroundingText != nil ? surroundingText : @""]; + [_inputMethodDelegate setSelection: start:end]; } void AvnTextInputMethod::SetCursorRect(AvnRect rect) { [_inputMethodDelegate setCursorRect: rect]; } + +void AvnTextInputMethod::SetSelectionInSurroundingText(int start, int end) { + [_inputMethodDelegate setSelection: start:end]; +} diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h b/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h index 9f321ca595..f5751d93b4 100644 --- a/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h +++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h @@ -14,6 +14,7 @@ -(void) setText:(NSString* _Nonnull) text; -(void) setCursorRect:(AvnRect) cursorRect; -(void) setSelection: (int) start : (int) end; +-(void) resetInputMethod; @end diff --git a/native/Avalonia.Native/src/OSX/AvnView.mm b/native/Avalonia.Native/src/OSX/AvnView.mm index 600dca865f..805cd7ee02 100644 --- a/native/Avalonia.Native/src/OSX/AvnView.mm +++ b/native/Avalonia.Native/src/OSX/AvnView.mm @@ -4,6 +4,7 @@ // #import +#import /* For the TIS functions used to classify the keyboard input source. */ #include "AvnView.h" #include "automation.h" #import "WindowInterfaces.h" @@ -22,8 +23,9 @@ NSMutableAttributedString* _text; NSRange _selectedRange; NSRange _markedRange; - NSEvent* _lastKeyDownEvent; NSMutableArray* _accessibilityChildren; + NSString* _keyboardInputSourceId; + bool _keyboardInputSourceComposes; } - (void)onClosed @@ -375,7 +377,15 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) [self becomeFirstResponder]; } } - + + if([self hasMarkedText] && + (type == LeftButtonDown || type == RightButtonDown || type == MiddleButtonDown || + type == XButton1Down || type == XButton2Down) && + [self inputContext] != nil) + { + [[self inputContext] handleEvent:event]; + } + auto parent = _parent.tryGet(); if(parent != nullptr) { @@ -641,9 +651,100 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) [super flagsChanged:event]; } -- (bool) handleKeyDown: (NSTimeInterval) timestamp withKey:(AvnKey)key withPhysicalKey:(AvnPhysicalKey)physicalKey withModifiers:(AvnInputModifiers)modifiers withKeySymbol:(NSString*)keySymbol { - auto parent = _parent.tryGet(); - return parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, modifiers, key, physicalKey, [keySymbol UTF8String]); +// The input mode an input method uses for direct alphanumeric typing. In this mode the input +// method passes keys straight through instead of composing, so keys must not be masked. +static CFStringRef const AlphanumericInputModeId = CFSTR("com.apple.inputmethod.Roman"); + +static bool InputSourceComposes(TISInputSourceRef source) +{ + // A plain keyboard layout never composes. Dead keys are not driven by the input source, they + // show up as a composition in progress and are covered by hasMarkedText. + auto type = static_cast(TISGetInputSourceProperty(source, kTISPropertyInputSourceType)); + + if(type == nullptr || CFEqual(type, kTISTypeKeyboardLayout)) + { + return false; + } + + auto mode = static_cast(TISGetInputSourceProperty(source, kTISPropertyInputModeID)); + + return mode == nullptr || !CFEqual(mode, AlphanumericInputModeId); +} + +// Whether this keystroke could be the one that starts a composition. Only a key that produces a +// printable character can, and masking any of the others would stop controls like TextBox from +// reacting to them at all. +static bool CanStartComposition(NSString* keySymbol, NSEventModifierFlags modifierFlags) +{ + // No symbol at all: the arrows, the function keys, Home, End and friends. + if(keySymbol == nullptr || [keySymbol length] == 0) + { + return false; + } + + // KeySymbolFromScanCode deliberately reports the control character for Backspace, Enter, Tab + // and Escape, and Forward Delete reports DEL (0x7F), so a symbol alone does not mean the key + // produces text. Those keys edit or cancel, they never start a composition. + auto firstChar = [keySymbol characterAtIndex:0]; + + if(firstChar < 0x20 || firstChar == 0x7F) + { + return false; + } + + // Command and Control combinations are shortcuts, which an input method does not consume. + return (modifierFlags & (NSEventModifierFlagCommand | NSEventModifierFlagControl)) == 0; +} + +// Returns true when the input source with the given id is an input method that can start a +// composition, as opposed to a plain keyboard layout or an input method in alphanumeric mode. +static bool KeyboardInputSourceComposes(NSString* sourceId) +{ + NSDictionary* filter = @{ (__bridge NSString*)kTISPropertyInputSourceID: sourceId }; + + // Include every installed source, not just the enabled ones: an input method mode can be + // selected without being listed as enabled, and failing to find it would classify a + // composing input method as a plain layout. + auto sources = TISCreateInputSourceList((__bridge CFDictionaryRef)filter, true); + + if(sources == nullptr) + { + return false; + } + + auto composes = CFArrayGetCount(sources) > 0 && + InputSourceComposes((TISInputSourceRef)CFArrayGetValueAtIndex(sources, 0)); + + CFRelease(sources); + + return composes; +} + +// Whether the currently selected keyboard input source is a composing input method. The result +// is cached per input source id, since this is queried for every key down. +- (bool)isComposingInputSourceSelected +{ + auto inputContext = [self inputContext]; + + if(inputContext == nullptr) + { + return false; + } + + NSString* sourceId = [inputContext selectedKeyboardInputSource]; + + if(sourceId == nullptr) + { + return false; + } + + if(![sourceId isEqualToString:_keyboardInputSourceId]) + { + _keyboardInputSourceId = sourceId; + _keyboardInputSourceComposes = KeyboardInputSourceComposes(sourceId); + } + + return _keyboardInputSourceComposes; } - (void)keyDown:(NSEvent *)event @@ -653,57 +754,57 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) { return; } - - _lastKeyDownEvent = event; - + auto timestamp = static_cast([event timestamp] * 1000); - + auto scanCode = [event keyCode]; - auto key = VirtualKeyFromScanCode(scanCode, [event modifierFlags]); auto physicalKey = PhysicalKeyFromScanCode(scanCode); auto keySymbol = KeySymbolFromScanCode(scanCode, [event modifierFlags]); - + auto keySymbolUtf8 = keySymbol == nullptr ? nullptr : [keySymbol UTF8String]; + auto modifiers = [self getModifiers:[event modifierFlags]]; - - //InputMethod is active - if(parent->InputMethod->IsActive()){ - auto hasInputModifier = modifiers != AvnInputModifiersNone; - - //Handle keyDown first if an input modifier is present - if(hasInputModifier){ - if([self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol]){ - //User code has handled the event - _lastKeyDownEvent = nullptr; - - return; - } - } - - if([[self inputContext] handleEvent:event] == NO){ - //KeyDown has not been consumed by the input context - - //Only raise a keyDown if we don't have a modifier - if(!hasInputModifier){ - [self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol]; - } - } - + + // The keystroke belongs to the input method when a composition is already in progress, since + // it then selects a candidate, commits or cancels it. It also belongs to the input method + // when a character key is typed into a text input client while a composing input method is + // selected, because that keystroke may be the one that starts the composition and we cannot + // know that before the input context has seen it. + // + // In both cases mask the key the way Win32 reports VK_PROCESSKEY, so that user code still + // observes a KeyDown but no KeyGesture can match it. The physical key and the key symbol + // keep their real values, so the underlying key remains recoverable. + // + // Both cases require an active client: without one the input context is never consulted, so + // marked text left over from a composition that lost its client mid-way must not mask keys. + auto imeProcessed = parent->InputMethod->IsActive() && + ([self hasMarkedText] || + (CanStartComposition(keySymbol, [event modifierFlags]) && + [self isComposingInputSourceSelected])); + + auto key = imeProcessed + ? AvnKeyImeProcessed + : VirtualKeyFromScanCode(scanCode, [event modifierFlags]); + + // A KeyDown is always raised before the input context sees the event, otherwise the + // input context silently consumes printable keys (space, letters, digits) and user code + // never gets a chance to react to them. + auto handled = parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, modifiers, key, physicalKey, keySymbolUtf8); + + if(handled) + { + // User code has handled the event, so no text may be produced from it. + return; } - //InputMethod not active - else{ - auto keyDownHandled = [self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol]; - - //Raise text input event for unhandled key down - if(!keyDownHandled){ - if(keySymbol != nullptr && key != AvnKeyEnter){ - auto timestamp = static_cast([event timestamp] * 1000); - - parent->TopLevelEvents->RawTextInputEvent(timestamp, [keySymbol UTF8String]); - } - } + + if(parent->InputMethod->IsActive()) + { + // Let the input context produce the text or drive the composition. + [[self inputContext] handleEvent:event]; + } + else if(keySymbol != nullptr && key != AvnKeyEnter) + { + parent->TopLevelEvents->RawTextInputEvent(timestamp, keySymbolUtf8); } - - _lastKeyDownEvent = nullptr; } - (void)keyUp:(NSEvent *)event @@ -713,9 +814,9 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) } - (void) doCommandBySelector:(SEL)selector{ - if(_lastKeyDownEvent != nullptr){ - [self keyboardEvent:_lastKeyDownEvent withType:KeyDown]; - } + // -keyDown: already raised a KeyDown for this event before handing it to the input + // context, so nothing is left to do here. The method still has to be implemented: + // falling back to NSResponder would perform the default action and emit a system beep. } - (AvnInputModifiers)getModifiers:(NSEventModifierFlags)mod @@ -747,25 +848,48 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) return (AvnInputModifiers)rv; } +// Clamps a range so that it can never be used to index outside of _text. +// Ranges reaching us from AppKit or from the managed side are not guaranteed to be valid. +- (NSRange)clampRangeToText:(NSRange)range +{ + if (range.location == NSNotFound) + return NSMakeRange(NSNotFound, 0); + + NSUInteger length = _text.length; + + if (range.location > length) + return NSMakeRange(length, 0); + + // Avoids the overflow of location + length that a plain bounds check would have. + return NSMakeRange(range.location, MIN(range.length, length - range.location)); +} + - (BOOL)hasMarkedText { - return _markedRange.length > 0; + return _markedRange.location != NSNotFound && _markedRange.length > 0; } - (NSRange)markedRange { - return _markedRange; + // From the docs: returns {NSNotFound, 0} if there is no marked range. + if (![self hasMarkedText]) + return NSMakeRange(NSNotFound, 0); + + // The preedit isn't necessarily part of the surrounding text we got from the managed side, + // so only the location is clamped here. An overlong length is handled by + // attributedSubstringForProposedRange:actualRange:, as the docs require. + return NSMakeRange(MIN(_markedRange.location, _text.length), _markedRange.length); } - (NSRange)selectedRange { - return _selectedRange; + return [self clampRangeToText:_selectedRange]; } - (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange { NSString* markedText; - + if([string isKindOfClass:[NSAttributedString class]]) { markedText = [string string]; @@ -774,24 +898,47 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) { markedText = (NSString*) string; } - - _markedRange = NSMakeRange(_selectedRange.location, [markedText length]); + + if (markedText == nil) + { + markedText = @""; + } + auto parent = _parent.tryGet(); - if(parent->InputMethod->IsActive()){ - parent->InputMethod->Client->SetPreeditText((char*)[markedText UTF8String]); + // Delete any replaced range + auto finalReplacementRange = [self clampRangeToText:replacementRange]; + + if (finalReplacementRange.location != NSNotFound && parent != nullptr && parent->InputMethod->IsActive()) + { + parent->InputMethod->Client->SelectInSurroundingText((int)finalReplacementRange.location, (int)(finalReplacementRange.location + finalReplacementRange.length)); + uint64_t timestamp = static_cast([NSDate timeIntervalSinceReferenceDate] * 1000); + parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, AvnInputModifiersNone, AvnKeyBack, AvnPhysicalKeyNone, "\b"); + parent->TopLevelEvents->RawKeyEvent(KeyUp, timestamp, AvnInputModifiersNone, AvnKeyBack, AvnPhysicalKeyNone, "\b"); + } + + auto markedLocation = [self selectedRange].location; + + _markedRange = NSMakeRange(markedLocation == NSNotFound ? 0 : markedLocation, [markedText length]); + + if (parent != nullptr && parent->InputMethod->IsActive()) + { + const char* utf8MarkedText = [markedText UTF8String]; + parent->InputMethod->Client->SetPreeditText((char*)(utf8MarkedText != nullptr ? utf8MarkedText : "")); } } - (void)unmarkText { auto parent = _parent.tryGet(); - if(parent->InputMethod->IsActive()){ + if(parent != nullptr && parent->InputMethod->IsActive()){ parent->InputMethod->Client->SetPreeditText(nullptr); } - - _markedRange = NSMakeRange(_selectedRange.location, 0); - + + auto selectionLocation = [self selectedRange].location; + + _markedRange = NSMakeRange(selectionLocation == NSNotFound ? 0 : selectionLocation, 0); + if([self inputContext]) { [[self inputContext] discardMarkedText]; } @@ -804,13 +951,27 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) - (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actualRange { - if(actualRange){ - range = *actualRange; + // From the docs: an implementation of this method should be prepared for aRange to be out of bounds. + // In this case, you should return the intersection of the document's range and aRange. + // If the location of aRange is completely outside of the document's range, return nil. + // actualRange is an out parameter: it is uninitialized on entry and must only be written to. + NSRange docRange = NSMakeRange(0, _text.length); + NSRange finalRange = NSIntersectionRange(range, docRange); + + if (finalRange.length == 0) + { + if (actualRange) { + *actualRange = NSMakeRange(NSNotFound, 0); + } + + return nil; } - - NSAttributedString* subString = [_text attributedSubstringFromRange:range]; - - return subString; + + if (actualRange) { + *actualRange = finalRange; + } + + return [_text attributedSubstringFromRange:finalRange]; } - (void)insertText:(id)string replacementRange:(NSRange)replacementRange @@ -819,9 +980,9 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) if(parent == nullptr){ return; } - + NSString* text; - + if([string isKindOfClass:[NSAttributedString class]]) { text = [string string]; @@ -830,12 +991,28 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) { text = (NSString*) string; } - + + if (text == nil) + { + text = @""; + } + + auto finalReplacementRange = [self clampRangeToText:replacementRange]; + + if (finalReplacementRange.location != NSNotFound && + ![self hasMarkedText] && + parent->InputMethod->IsActive()) + { + parent->InputMethod->Client->SelectInSurroundingText((int)finalReplacementRange.location, (int)(finalReplacementRange.location + finalReplacementRange.length)); + } + [self unmarkText]; - + uint64_t timestamp = static_cast([NSDate timeIntervalSinceReferenceDate] * 1000); - - parent->TopLevelEvents->RawTextInputEvent(timestamp, [text UTF8String]); + + const char* utf8Text = [text UTF8String]; + + parent->TopLevelEvents->RawTextInputEvent(timestamp, utf8Text != nullptr ? utf8Text : ""); } - (NSUInteger)characterIndexForPoint:(NSPoint)point @@ -845,11 +1022,17 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange { + // actualRange is an out parameter: it is uninitialized on entry and must only be written to. + // We only ever report a single rect, so the requested range is echoed back clamped to the document. + if (actualRange) { + *actualRange = [self clampRangeToText:range]; + } + auto parent = _parent.tryGet(); - if(!parent->InputMethod->IsActive()){ + if(parent == nullptr || !parent->InputMethod->IsActive()){ return NSZeroRect; } - + return _cursorRect; } @@ -1003,11 +1186,45 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) } - (void) setText:(NSString *)text{ - [[_text mutableString] setString:text]; + [[_text mutableString] setString:text != nil ? text : @""]; + + // The document changed, so the stored ranges can now point outside of it. + _selectedRange = [self clampRangeToText:_selectedRange]; + + if (_markedRange.location != NSNotFound) + { + _markedRange = NSMakeRange(MIN(_markedRange.location, _text.length), _markedRange.length); + } } - (void) setSelection:(int)start :(int)end{ - _selectedRange = NSMakeRange(start, end - start); + if (end < start) + { + auto temp = start; + start = end; + end = temp; + } + + auto length = (int)_text.length; + + start = MAX(0, MIN(start, length)); + end = MAX(start, MIN(end, length)); + + _selectedRange = NSMakeRange((NSUInteger)start, (NSUInteger)(end - start)); +} + +- (void) resetInputMethod{ + auto parent = _parent.tryGet(); + + if(parent != nullptr && parent->InputMethod->IsActive()){ + parent->InputMethod->Client->SetPreeditText(nullptr); + } + + _markedRange = NSMakeRange(_selectedRange.location, 0); + + if([self inputContext]) { + [[self inputContext] discardMarkedText]; + } } - (void) setCursorRect:(AvnRect)rect{ diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index b2f20d556b..de0c796d4a 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -628,14 +628,18 @@ return GetNSStringAndRelease(automationPeer->GetAutomationId()); } -- (IAvnAutomationPeer* _Nonnull) automationPeer +- (IAvnAutomationPeer* _Nullable) automationPeer { auto parent = _parent.tryGet(); if (parent && _automationPeer == nullptr) { - _automationPeer = parent->BaseEvents->GetAutomationPeer(); - _automationNode = new AvnAutomationNode(self); - _automationPeer->SetNode(_automationNode); + auto peer = parent->BaseEvents->GetAutomationPeer(); + if (peer != nullptr) + { + _automationPeer = peer; + _automationNode = new AvnAutomationNode(self); + _automationPeer->SetNode(_automationNode); + } } return _automationPeer; diff --git a/native/Avalonia.Native/src/OSX/PlatformSettings.mm b/native/Avalonia.Native/src/OSX/PlatformSettings.mm index 34ceb93186..d30d55e2ec 100644 --- a/native/Avalonia.Native/src/OSX/PlatformSettings.mm +++ b/native/Avalonia.Native/src/OSX/PlatformSettings.mm @@ -1,12 +1,19 @@ #include "common.h" +#include "AvnString.h" @interface CocoaThemeObserver : NSObject -(id)initWithCallback:(IAvnActionCallback *)callback; @end +@interface CocoaLocaleObserver : NSObject +-(id)initWithCallback:(IAvnActionCallback *)callback; +-(void)localeDidChange:(NSNotification *)notification; +@end + class PlatformSettings : public ComSingleObject { CocoaThemeObserver* observer; + CocoaLocaleObserver* localeObserver; public: FORWARD_IUNKNOWN() @@ -58,6 +65,28 @@ public: [[NSApplication sharedApplication] addObserver:observer forKeyPath:@"effectiveAppearance" options:NSKeyValueObservingOptionNew context:nil]; } } + + virtual HRESULT GetPreferredLanguage(IAvnString** ret) override + { + @autoreleasepool + { + if (ret == nullptr) + return E_POINTER; + + auto language = [[NSLocale preferredLanguages] firstObject]; + *ret = language == nil ? nullptr : CreateAvnString(language); + return S_OK; + } + } + + virtual void RegisterLanguageChange(IAvnActionCallback *callback) override + { + localeObserver = [[CocoaLocaleObserver alloc] initWithCallback: callback]; + [[NSNotificationCenter defaultCenter] addObserver:localeObserver + selector:@selector(localeDidChange:) + name:NSCurrentLocaleDidChangeNotification + object:nil]; + } private: unsigned int to_argb(NSColor* color) @@ -107,6 +136,23 @@ private: } @end +@implementation CocoaLocaleObserver +{ + ComPtr _callback; +} +- (id) initWithCallback:(IAvnActionCallback *)callback { + self = [super init]; + if (self) { + _callback = callback; + } + return self; +} + +- (void)localeDidChange:(NSNotification *)notification { + _callback->Run(); +} +@end + extern IAvnPlatformSettings* CreatePlatformSettings() { return new PlatformSettings(); diff --git a/native/Avalonia.Native/src/OSX/PopupImpl.mm b/native/Avalonia.Native/src/OSX/PopupImpl.mm index 13131bf91e..8a188c89b3 100644 --- a/native/Avalonia.Native/src/OSX/PopupImpl.mm +++ b/native/Avalonia.Native/src/OSX/PopupImpl.mm @@ -43,6 +43,17 @@ public: return WindowBaseImpl::Show(activate, true); } + virtual HRESULT SetHitTestVisible(bool value) override + { + START_COM_CALL; + + @autoreleasepool + { + [Window setIgnoresMouseEvents:!value]; + return S_OK; + } + } + virtual bool ShouldTakeFocusOnShow() override { auto parent = Parent.tryGet(); diff --git a/native/Avalonia.Native/src/OSX/StorageProvider.mm b/native/Avalonia.Native/src/OSX/StorageProvider.mm index a608f6ffaf..8e3ad185de 100644 --- a/native/Avalonia.Native/src/OSX/StorageProvider.mm +++ b/native/Avalonia.Native/src/OSX/StorageProvider.mm @@ -165,6 +165,17 @@ public: return nullptr; } + + static int GetSelectedFilterIndex(NSSavePanel* _Nonnull panel) + { + if (panel.accessoryView != nil) + { + auto popup = [panel.accessoryView viewWithTag:kFileTypePopupTag]; + if ([popup isKindOfClass:[NSPopUpButton class]]) + return (int)[(NSPopUpButton*)popup indexOfSelectedItem]; + } + return -1; + } virtual void SelectFolderDialog (IAvnTopLevel* parentTopLevel, IAvnSystemDialogEvents* events, @@ -181,6 +192,7 @@ public: panel.canChooseDirectories = true; panel.canCreateDirectories = true; panel.canChooseFiles = false; + panel.treatsFilePackagesAsDirectories = true; if(title != nullptr) { @@ -270,30 +282,32 @@ public: auto parentWindow = GetEffectiveNSWindow(parentTopLevel); auto handler = ^(NSModalResponse result) { + auto selectedIndex = GetSelectedFilterIndex(panel); + if(result == NSFileHandlingPanelOKButton) { auto urls = [panel URLs]; - + if(urls.count > 0) { auto uriStrings = CreateAvnStringArray(urls); - ownedEvents->OnCompleted(uriStrings); + ownedEvents->OnCompletedWithFilter(uriStrings, selectedIndex); [panel orderOut:panel]; - + if (parentWindow != nullptr) { [parentWindow makeKeyAndOrderFront:parentWindow]; } - + return; } } - - ownedEvents->OnCompleted(nullptr); - + + ownedEvents->OnCompletedWithFilter(nullptr, selectedIndex); + }; - + if (parentWindow != nullptr) { [panel beginSheetModalForWindow:parentWindow completionHandler:handler]; @@ -304,7 +318,7 @@ public: } } } - + virtual void SaveFileDialog (IAvnTopLevel* parentTopLevel, IAvnSystemDialogEvents* events, const char* title, @@ -340,15 +354,7 @@ public: auto parentWindow = GetEffectiveNSWindow(parentTopLevel); auto handler = ^(NSModalResponse result) { - int selectedIndex = -1; - if (panel.accessoryView != nil) - { - auto popup = [panel.accessoryView viewWithTag:kFileTypePopupTag]; - if ([popup isKindOfClass:[NSPopUpButton class]]) - { - selectedIndex = (int)[(NSPopUpButton*)popup indexOfSelectedItem]; - } - } + auto selectedIndex = GetSelectedFilterIndex(panel); if(result == NSFileHandlingPanelOKButton) { diff --git a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm index 2a1de62b05..205f1efbdf 100644 --- a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm +++ b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm @@ -21,6 +21,24 @@ @class AutoFitContentView; + +// Activates the app, but defers until the run loop is running if it isn't yet. +// Windows are shown (and the app activated) from OnFrameworkInitializationCompleted, +// which runs before -[NSApplication run]. Activating that early yields a "degraded" +// activation: the app looks frontmost but the WindowServer never does a real +// become-active, which leaves the out-of-process file picker unable to receive +// clicks in its file list/sidebar until a manual app switch. Deferring makes the first +// activation a clean transition. +static void ActivateApplication() { + if ([NSApp isRunning]) { + [NSApp activateIgnoringOtherApps:YES]; + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp activateIgnoringOtherApps:YES]; + }); + } +} + WindowBaseImpl::~WindowBaseImpl() { View = nullptr; Window = nullptr; @@ -99,7 +117,7 @@ HRESULT WindowBaseImpl::Show(bool activate, bool isDialog) { [Window orderFront:Window]; [Window makeKeyAndOrderFront:Window]; [Window makeFirstResponder:View]; - [NSApp activateIgnoringOtherApps:YES]; + ActivateApplication(); } else { [Window orderFront:Window]; } @@ -177,7 +195,7 @@ HRESULT WindowBaseImpl::Activate() { @autoreleasepool { if (Window != nullptr) { [Window makeKeyAndOrderFront:nil]; - [NSApp activateIgnoringOtherApps:YES]; + ActivateApplication(); } } @@ -329,6 +347,21 @@ HRESULT WindowBaseImpl::BeginMoveDrag() { @autoreleasepool { auto lastEvent = [View lastMouseDownEvent]; + if (lastEvent == nullptr) { + // A press that begins inside an embedded native view (for example a webview hosted + // through NativeControlHost) is consumed by that view and never delivered to the + // Avalonia view, so no mouse-down is recorded; fall back to the event the + // application is tracking right now, provided it is a left-button press or drag + // that belongs to this window. + auto currentEvent = [NSApp currentEvent]; + + if (currentEvent != nullptr && [currentEvent window] == Window && + ([currentEvent type] == NSEventTypeLeftMouseDown || + [currentEvent type] == NSEventTypeLeftMouseDragged)) { + lastEvent = currentEvent; + } + } + if (lastEvent == nullptr) { return S_OK; } @@ -432,11 +465,21 @@ void WindowBaseImpl::CleanNSWindow() { } void WindowBaseImpl::CreateNSWindow(bool usePanel) { + // Anchor the content rect to the primary screen's origin. It's usually at (0,0), but can be offset while the + // display server is mid-reconfiguration. This ensures the window is always on a proper screen. + // See https://github.com/AvaloniaUI/Avalonia/issues/18895 + NSPoint origin = NSZeroPoint; + NSArray* screens = [NSScreen screens]; + if (screens.count > 0) + origin = screens.firstObject.frame.origin; + + NSRect contentRect = NSRect { origin.x, origin.y, lastSize }; + if (usePanel) { - Window = [[AvnPanel alloc] initWithParent:this contentRect:NSRect{0, 0, lastSize} styleMask:NSWindowStyleMaskBorderless]; + Window = [[AvnPanel alloc] initWithParent:this contentRect:contentRect styleMask:NSWindowStyleMaskBorderless]; [Window setHidesOnDeactivate:false]; } else { - Window = [[AvnWindow alloc] initWithParent:this contentRect:NSRect{0, 0, lastSize} styleMask:NSWindowStyleMaskBorderless]; + Window = [[AvnWindow alloc] initWithParent:this contentRect:contentRect styleMask:NSWindowStyleMaskBorderless]; } } diff --git a/native/Avalonia.Native/src/OSX/WindowProtocol.h b/native/Avalonia.Native/src/OSX/WindowProtocol.h index 5d1df951a6..0e1091b86c 100644 --- a/native/Avalonia.Native/src/OSX/WindowProtocol.h +++ b/native/Avalonia.Native/src/OSX/WindowProtocol.h @@ -17,7 +17,7 @@ struct IAvnAutomationPeer; -(void) showAppMenuOnly; -(void) showWindowMenuWithAppMenu; -(void) applyMenu:(AvnMenu* _Nullable)menu; --(IAvnAutomationPeer* _Nonnull) automationPeer; +-(IAvnAutomationPeer* _Nullable) automationPeer; -(double) getExtendedTitleBarHeight; -(void) setIsExtended:(bool)value; diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index 6fd2cb6218..8750e9986c 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -96,7 +96,7 @@ case AutomationEdit: return NSAccessibilityTextFieldRole; case AutomationHyperlink: return NSAccessibilityLinkRole; case AutomationImage: return NSAccessibilityImageRole; - case AutomationListItem: return NSAccessibilityGroupRole; + case AutomationListItem: return NSAccessibilityRowRole; case AutomationList: return NSAccessibilityListRole; case AutomationMenu: return NSAccessibilityMenuBarRole; case AutomationMenuBar: return NSAccessibilityMenuBarRole; @@ -141,6 +141,7 @@ auto controlType = _peer->GetAutomationControlType(); switch (controlType) { case AutomationList: return @"AXContentList"; + case AutomationListItem: return NSAccessibilityTableRowSubrole; } auto landmarkType = _peer->GetLandmarkType(); @@ -259,20 +260,36 @@ - (void)setAccessibilityValue:(id)newValue { - if (_peer->IsValueProvider()) + if (!_peer->IsEnabled()) + return; + if (_peer->IsValueProvider() && !_peer->ValueProvider_IsReadOnly()) { if (newValue == nil) _peer->ValueProvider_SetValue(nil); else if ([newValue isKindOfClass:[NSString class]]) _peer->ValueProvider_SetValue([(NSString*)newValue UTF8String]); } - else if (_peer->IsRangeValueProvider()) + else if (_peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly()) { if ([newValue isKindOfClass:[NSNumber class]]) _peer->RangeValueProvider_SetValue([(NSNumber*)newValue doubleValue]); } } +- (BOOL)accessibilityIsAttributeSettable:(NSAccessibilityAttributeName)attribute +{ + if ([attribute isEqualToString:NSAccessibilityValueAttribute]) + { + if (_peer->IsValueProvider()) + return !_peer->ValueProvider_IsReadOnly(); + if (_peer->IsRangeValueProvider()) + return !_peer->RangeValueProvider_IsReadOnly(); + return NO; + } + + return [super accessibilityIsAttributeSettable:attribute]; +} + - (id)accessibilityMinValue { if (_peer->IsRangeValueProvider()) @@ -357,6 +374,20 @@ return [AvnAccessibilityElement acquire:rootPeer]; } +- (id)accessibilityHorizontalScrollBar +{ + if (_peer == nullptr) + return nil; + return [AvnAccessibilityElement acquire:_peer->ScrollProvider_GetHorizontalScrollBar()]; +} + +- (id)accessibilityVerticalScrollBar +{ + if (_peer == nullptr) + return nil; + return [AvnAccessibilityElement acquire:_peer->ScrollProvider_GetVerticalScrollBar()]; +} + - (BOOL)isAccessibilityExpanded { if (!_peer->IsExpandCollapseProvider()) @@ -366,7 +397,7 @@ - (void)setAccessibilityExpanded:(BOOL)accessibilityExpanded { - if (!_peer->IsExpandCollapseProvider()) + if (!_peer->IsExpandCollapseProvider() || !_peer->IsEnabled()) return; if (accessibilityExpanded) _peer->ExpandCollapseProvider_Expand(); @@ -376,13 +407,18 @@ - (BOOL)accessibilityPerformPress { + if (!_peer->IsEnabled()) + return NO; if (_peer->IsInvokeProvider()) { _peer->InvokeProvider_Invoke(); } else if (_peer->IsExpandCollapseProvider()) { - _peer->ExpandCollapseProvider_Expand(); + if (_peer->ExpandCollapseProvider_GetIsExpanded()) + _peer->ExpandCollapseProvider_Collapse(); + else + _peer->ExpandCollapseProvider_Expand(); } else if (_peer->IsToggleProvider()) { @@ -393,7 +429,7 @@ - (BOOL)accessibilityPerformIncrement { - if (!_peer->IsRangeValueProvider()) + if (!_peer->IsRangeValueProvider() || _peer->RangeValueProvider_IsReadOnly() || !_peer->IsEnabled()) return NO; auto value = _peer->RangeValueProvider_GetValue(); value += _peer->RangeValueProvider_GetSmallChange(); @@ -403,7 +439,7 @@ - (BOOL)accessibilityPerformDecrement { - if (!_peer->IsRangeValueProvider()) + if (!_peer->IsRangeValueProvider() || _peer->RangeValueProvider_IsReadOnly() || !_peer->IsEnabled()) return NO; auto value = _peer->RangeValueProvider_GetValue(); value -= _peer->RangeValueProvider_GetSmallChange(); @@ -413,7 +449,7 @@ - (BOOL)accessibilityPerformShowMenu { - if (!_peer->IsExpandCollapseProvider()) + if (!_peer->IsExpandCollapseProvider() || !_peer->IsEnabled()) return NO; _peer->ExpandCollapseProvider_Expand(); return YES; @@ -445,11 +481,22 @@ return NO; } +- (void)setAccessibilitySelected:(BOOL)accessibilitySelected +{ + if (!_peer->IsSelectionItemProvider() || !_peer->IsEnabled()) + return; + if (accessibilitySelected) + _peer->SelectionItemProvider_AddToSelection(); + else + _peer->SelectionItemProvider_RemoveFromSelection(); +} + - (BOOL)isAccessibilitySelectorAllowed:(SEL)selector { if (selector == @selector(setAccessibilityValue:)) { - return _peer->IsValueProvider() || _peer->IsRangeValueProvider(); + return (_peer->IsValueProvider() && !_peer->ValueProvider_IsReadOnly()) || + (_peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly()); } else if (selector == @selector(accessibilityPerformShowMenu)) { @@ -463,9 +510,16 @@ { return _peer->IsInvokeProvider() || _peer->IsExpandCollapseProvider() || _peer->IsToggleProvider(); } + else if (selector == @selector(setAccessibilitySelected:)) + { + return _peer->IsSelectionItemProvider(); + } else if (selector == @selector(accessibilityPerformIncrement) || - selector == @selector(accessibilityPerformDecrement) || - selector == @selector(accessibilityMinValue) || + selector == @selector(accessibilityPerformDecrement)) + { + return _peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly(); + } + else if (selector == @selector(accessibilityMinValue) || selector == @selector(accessibilityMaxValue)) { return _peer->IsRangeValueProvider(); @@ -501,8 +555,31 @@ if (_children) [changed addObjectsFromArray:_children]; + /* + For future reference, upon testing with a sample SwiftUI app: + + 1) Containers vanish. VStack/HStack don't appear in the accessibility tree at all, + only real elements (Text, Button, List) do, parented to one root AXHostingView. + + 2) Changes post on the nearest real element, not the container. + Toggling a child two VStack/HStack levels deep fired AXLayoutChanged on the AXHostingView + (the nearest real element), never on the hidden containers. + + 3) Real controls get their own notification. The List (AXOutline) posted AXRowCountChanged on itself. + + Apple never posts a structural notification on a non-accessibility container. + It targets the nearest element that's actually in the tree. + That's exactly what the code below does (templated parent, else walk to the nearest exposed ancestor). + */ + + id target = [AvnAccessibilityElement acquire:_peer->GetTemplatedParent()]; + if (target == nil) + target = self; + while ([target isKindOfClass:[AvnAccessibilityElement class]] && ![(AvnAccessibilityElement*)target isAccessibilityElement]) + target = [(AvnAccessibilityElement*)target accessibilityParent]; + NSAccessibilityPostNotificationWithUserInfo( - self, + target, NSAccessibilityLayoutChangedNotification, @{ NSAccessibilityUIElementsKey: [changed allObjects]}); } diff --git a/native/Avalonia.Native/src/OSX/menu.mm b/native/Avalonia.Native/src/OSX/menu.mm index a67c96a796..e664ee0ad6 100644 --- a/native/Avalonia.Native/src/OSX/menu.mm +++ b/native/Avalonia.Native/src/OSX/menu.mm @@ -101,20 +101,29 @@ NSMenuItem* AvnAppMenuItem::GetNative() HRESULT AvnAppMenuItem::SetSubMenu (IAvnMenu* menu) { START_COM_CALL; - + @autoreleasepool { if(menu != nullptr) { auto nsMenu = dynamic_cast(menu)->GetNative(); - + [_native setSubmenu: nsMenu]; + + // Parity with -[NSMenu setSubmenu:forItem:]: without submenuAction: a click is dispatched to + // didSelectItem: and dismisses the menu instead of opening the submenu. + [_native setTarget: nil]; + [_native setAction: @selector(submenuAction:)]; } else { [_native setSubmenu: nullptr]; + + // The item is reused, so put its own action back. + [_native setTarget: _native]; + [_native setAction: @selector(didSelectItem:)]; } - + return S_OK; } } diff --git a/native/Avalonia.Native/src/OSX/metal.mm b/native/Avalonia.Native/src/OSX/metal.mm index 3da1282306..05f9fe0e34 100644 --- a/native/Avalonia.Native/src/OSX/metal.mm +++ b/native/Avalonia.Native/src/OSX/metal.mm @@ -181,12 +181,13 @@ class AvnMetalRenderSession : public ComSingleObject drawable, const AvnPixelSize &size, double scaling) + AvnMetalRenderSession(AvnMetalDevice* device, CAMetalLayer* layer, id drawable, const AvnPixelSize &size, double scaling, bool presentWithTransaction) : _drawable(drawable), _size(size), _scaling(scaling), _queue(device->queue), - _texture([drawable texture]) { + _texture([drawable texture]), _presentWithTransaction(presentWithTransaction) { _layer = layer; } @@ -207,8 +208,19 @@ public: { START_ARP_CALL; auto buffer = [_queue commandBuffer]; - [buffer presentDrawable: _drawable]; - [buffer commit]; + if(_presentWithTransaction) + { + [buffer commit]; + [buffer waitUntilScheduled]; + [_drawable present]; + // Restore the default asynchronous presentation for the off-thread render loop. + _layer.presentsWithTransaction = NO; + } + else + { + [buffer presentDrawable: _drawable]; + [buffer commit]; + } } }; @@ -230,7 +242,8 @@ public: HRESULT BeginDrawing(IAvnMetalRenderingSession **ret) override { START_COM_ARP_CALL; - if([NSThread isMainThread]) + bool onMainThread = [NSThread isMainThread]; + if(onMainThread) { // Flush all existing rendering auto buffer = [_device->queue commandBuffer]; @@ -240,15 +253,21 @@ public: _scaling= PendingScaling; CGSize layerSize = {(CGFloat)_size.Width, (CGFloat)_size.Height}; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; [_layer setDrawableSize: layerSize]; + _layer.presentsWithTransaction = YES; + [CATransaction commit]; } auto drawable = [_layer nextDrawable]; if(drawable == nil) { - ret = nil; + if(onMainThread) + _layer.presentsWithTransaction = NO; + *ret = nullptr; return E_FAIL; } - *ret = new AvnMetalRenderSession(_device, _layer, drawable, _size, _scaling); + *ret = new AvnMetalRenderSession(_device, _layer, drawable, _size, _scaling, onMainThread); return 0; } }; diff --git a/nukebuild/ApiDiffHelper.cs b/nukebuild/ApiDiffHelper.cs index d16495969e..8325ca41f4 100644 --- a/nukebuild/ApiDiffHelper.cs +++ b/nukebuild/ApiDiffHelper.cs @@ -399,7 +399,11 @@ public static class ApiDiffHelper { var packageSource = new PackageSource(NightlyFeedUri) { ProtocolVersion = 3 }; var repository = Repository.Factory.GetCoreV3(packageSource); - var findPackageByIdResource = await repository.GetResourceAsync(); + + var findPackageByIdResource = + await repository.GetResourceAsync() ?? + throw new InvalidOperationException($"{nameof(FindPackageByIdResource)} not found on {packageSource}"); + return new NuGetDownloadContext(packageSource, findPackageByIdResource); } diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index e09204485f..5bcd8d60ef 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -43,6 +43,9 @@ partial class Build : NukeBuild [NuGetPackage("dotnet-ilrepack", "ILRepackTool.dll", Framework = "net8.0")] Tool IlRepackTool; + [NuGetPackage("CycloneDX", "CycloneDX.dll", Framework = "net10.0")] + Tool CycloneDxTool; + protected override void OnBuildInitialized() { Parameters = new BuildParameters(this, ScheduledTargets.Contains(BuildToNuGetCache)); @@ -227,6 +230,10 @@ partial class Build : NukeBuild { tfm = "net8.0"; } + if (tfm == "$(AvsCurrentWindowsTargetFramework)") + { + tfm = "net10.0-windows"; + } if (tfm.StartsWith("net4") && (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) @@ -280,6 +287,9 @@ partial class Build : NukeBuild RunCoreTest("Avalonia.Headless.XUnit.PerAssembly.UnitTests"); RunCoreTest("Avalonia.Headless.XUnit.PerTest.UnitTests"); RunCoreTest("Avalonia.Themes.Fluent2.UnitTests"); + + if (Parameters.IsRunningOnWindows) + RunCoreTest("Avalonia.UnitTests.WpfCompare"); }); Target RunRenderTests => _ => _ @@ -309,7 +319,9 @@ partial class Build : NukeBuild }); Target ZipFiles => _ => _ - .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package) + // CreateSbom embeds the SBOM into each .nupkg in NugetRoot, so it must run before we zip + // that directory - otherwise the zipped NuGet artifacts would omit the embedded SBOM. + .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package, CreateSbom) .Executes(() => { var data = Parameters; @@ -341,6 +353,20 @@ partial class Build : NukeBuild Parameters.NugetRoot / $"Avalonia.{Parameters.Version}.snupkg"); }); + Target CreateSbom => _ => _ + .DependsOn(CreateNugetPackages) + .Executes(() => + { + SbomGenerator.Generate( + CycloneDxTool, + RootDirectory, + Parameters.NugetRoot, + Parameters.NugetIntermediateRoot, + RootDirectory / "nukebuild" / "numerge.json", + Parameters.SbomRoot, + Parameters.Version); + }); + Target DownloadApiBaselinePackages => _ => _ .DependsOn(CreateNugetPackages) .Executes(async () => @@ -410,12 +436,14 @@ partial class Build : NukeBuild Target CiAzureOSX => _ => _ .DependsOn(Package) - .DependsOn(ZipFiles); + .DependsOn(ZipFiles) + .DependsOn(CreateSbom); Target CiAzureWindows => _ => _ .DependsOn(Package) .DependsOn(VerifyXamlCompilation) - .DependsOn(ZipFiles); + .DependsOn(ZipFiles) + .DependsOn(CreateSbom); Target BuildToNuGetCache => _ => _ .DependsOn(CreateNugetPackages) @@ -429,12 +457,10 @@ partial class Build : NukeBuild foreach (var path in Parameters.NugetRoot.GlobFiles("*.nupkg")) { + var packageId = SbomGenerator.ReadPackageId(path); + using var f = File.Open(path.ToString(), FileMode.Open, FileAccess.Read); using var zip = new ZipArchive(f, ZipArchiveMode.Read); - var nuspecEntry = zip.Entries.First(e => e.FullName.EndsWith(".nuspec") && e.FullName == e.Name); - var packageId = XDocument.Load(nuspecEntry.Open()).Document.Root - .Elements().First(x => x.Name.LocalName == "metadata") - .Elements().First(x => x.Name.LocalName == "id").Value; var packagePath = Path.Combine( globalPackagesFolder, diff --git a/nukebuild/BuildParameters.cs b/nukebuild/BuildParameters.cs index 4b55e1691e..18d9e154ca 100644 --- a/nukebuild/BuildParameters.cs +++ b/nukebuild/BuildParameters.cs @@ -60,6 +60,7 @@ public partial class Build public AbsolutePath ArtifactsDir { get; } public AbsolutePath NugetIntermediateRoot { get; } public AbsolutePath NugetRoot { get; } + public AbsolutePath SbomRoot { get; } public AbsolutePath ZipRoot { get; } public AbsolutePath TestResultsRoot { get; } public string DirSuffix { get; } @@ -139,6 +140,7 @@ public partial class Build ArtifactsDir = RootDirectory / "artifacts"; NugetRoot = ArtifactsDir / "nuget"; NugetIntermediateRoot = RootDirectory / "build-intermediate" / "nuget"; + SbomRoot = ArtifactsDir / "sbom"; ZipRoot = ArtifactsDir / "zip"; TestResultsRoot = ArtifactsDir / "test-results"; BuildDirs = RootDirectory.GlobDirectories("**/bin") diff --git a/nukebuild/SbomGenerator.cs b/nukebuild/SbomGenerator.cs new file mode 100644 index 0000000000..a35620f2db --- /dev/null +++ b/nukebuild/SbomGenerator.cs @@ -0,0 +1,740 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using static Serilog.Log; + +// Generates one CycloneDX SBOM per published NuGet package, using the already-restored +// solution (obj/project.assets.json) so component versions are the ones actually resolved +// into the build, rather than the floating PackageReference ranges GitHub's own dependency +// graph reports. +// +// Numerge (see numerge.json) merges several source projects' packed output into a handful of +// the final NuGet packages (e.g. "Avalonia" absorbs Avalonia.Base, Avalonia.Controls, the +// build tasks/analyzers, etc). That merge happens on the built .nupkg files, not via MSBuild +// ProjectReferences, so it can't be discovered by pointing CycloneDX at a single project with +// -rs/--recursive. Instead this compares the intermediate and final package sets to work out +// which source projects were folded into which final package, generates a BOM per constituent +// project, and unions their components (de-duplicated by purl) into one BOM per final package. +public static class SbomGenerator +{ + public static void Generate( + Tool cycloneDx, + AbsolutePath rootDirectory, + AbsolutePath nugetRoot, + AbsolutePath nugetIntermediateRoot, + AbsolutePath numergeConfigPath, + AbsolutePath outputDirectory, + string version) + { + outputDirectory.CreateOrCleanDirectory(); + + // Read each final package's id exactly once here (opening/unzipping a nupkg to parse its + // nuspec isn't free) and reuse the id->path map when locating each package's own nupkg + // below, rather than re-scanning every nupkg per final package. + var finalPackagePathsById = nugetRoot.GlobFiles("*.nupkg") + .GroupBy(p => ReadPackageId((string)p)) + .ToDictionary(g => g.Key, g => g.First()); + var finalPackageIds = finalPackagePathsById.Keys.ToHashSet(); + var intermediatePackageIds = nugetIntermediateRoot.GlobFiles("*.nupkg") + .Select(p => ReadPackageId((string)p)).Distinct(); + + var numerge = Numerge.MergeConfiguration.LoadFile(numergeConfigPath); + var explicitParentByChild = numerge.Packages + .SelectMany(p => p.Merge.Select(c => (Parent: p.Id, Child: c.Id))) + .ToDictionary(x => x.Child, x => x.Parent); + var mergeAllParent = numerge.Packages.FirstOrDefault(p => p.MergeAll)?.Id; + + // Every final package starts out as its own sole constituent; leftover intermediate + // packages (ones that never shipped standalone) get assigned to whichever final + // package absorbed them, per numerge.json. + var constituentProjectIdsByFinalId = finalPackageIds.ToDictionary(id => id, id => new List { id }); + foreach (var id in intermediatePackageIds.Where(id => !finalPackageIds.Contains(id))) + { + var owner = explicitParentByChild.TryGetValue(id, out var explicitOwner) ? explicitOwner : mergeAllParent; + if (owner is not null && constituentProjectIdsByFinalId.TryGetValue(owner, out var siblings)) + siblings.Add(id); + else + Warning($"SBOM: couldn't determine which published package absorbs intermediate package '{id}' - it will be missing from all generated SBOMs."); + } + + foreach (var (finalId, projectIds) in constituentProjectIdsByFinalId) + GenerateForPackage(cycloneDx, rootDirectory, finalPackagePathsById, outputDirectory, version, finalId, projectIds); + } + + static void GenerateForPackage(Tool cycloneDx, AbsolutePath rootDirectory, + IReadOnlyDictionary finalPackagePathsById, + AbsolutePath outputDirectory, string version, string finalId, List projectIds) + { + JsonObject? merged = null; + var seenComponentKeys = new HashSet(); + var scannedProjectDirs = new List(); + + foreach (var projectId in projectIds) + { + var project = rootDirectory.GlobFiles($"src/**/{projectId}.csproj") + .Concat(rootDirectory.GlobFiles($"packages/**/{projectId}.csproj")) + .FirstOrDefault(); + if (project is null) + { + Warning($"SBOM: couldn't locate source project for '{projectId}', skipping it in the SBOM for '{finalId}'."); + continue; + } + scannedProjectDirs.Add(project.Parent); + + var tempBom = outputDirectory / $"_{projectId}.tmp.json"; + cycloneDx( + $"\"{project}\" -o \"{outputDirectory}\" -fn \"{tempBom.Name}\" -F Json -dpr -ed -sn \"{finalId}\" -sv \"{version}\"", + workingDirectory: rootDirectory); + + var doc = JsonNode.Parse(File.ReadAllText(tempBom))!.AsObject(); + File.Delete(tempBom); + + var components = doc["components"]?.AsArray() ?? new JsonArray(); + if (merged is null) + { + merged = doc; + foreach (var component in components) + seenComponentKeys.Add(ComponentKey(component)); + } + else + { + var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray()); + foreach (var component in components) + { + if (seenComponentKeys.Add(ComponentKey(component))) + target.Add(component!.DeepClone()); + } + + // Every constituent project's own -sn/-sv override makes its root component (and + // therefore its dependency-graph "ref") identical to the final package's, so merging + // by ref correctly unions all constituents' dependsOn edges onto that shared root + // instead of silently keeping only the first project's edges. + MergeDependencyGraph(merged, doc["dependencies"]?.AsArray() ?? new JsonArray()); + } + } + + if (merged is null) + { + Warning($"SBOM: no source projects could be scanned for '{finalId}', no SBOM was generated for it."); + return; + } + + // cyclonedx-dotnet only sees the MSBuild/NuGet graph. Some projects also bundle a + // Bun/npm-built webapp directly into their published package (e.g. Avalonia.Browser's + // staticwebassets, Avalonia.DesignerSupport's embedded previewer) - scan those separately + // so their shipped JS dependencies aren't silently absent from the SBOM. + var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue(); + foreach (var projectDir in scannedProjectDirs) + AddNpmComponents(merged, seenComponentKeys, projectDir, rootRef); + + // The final .nupkg carries the authoritative publisher/license/repository metadata and the + // actual shipped binaries; use it to flesh out the thin root component cyclonedx-dotnet + // emits and to verify nothing ships that the dependency scan didn't already account for. + var finalNupkg = finalPackagePathsById.GetValueOrDefault(finalId); + if (finalNupkg is not null) + { + var nuspec = ReadNuspecMetadata((string)finalNupkg); + EnrichRootComponent(merged, nuspec); + AddPackageContentComponents(merged, seenComponentKeys, (string)finalNupkg, finalId, projectIds, nuspec); + } + else + { + Warning($"SBOM: couldn't find the built .nupkg for '{finalId}' - root metadata and package-content verification were skipped."); + } + + // cyclonedx-dotnet leaves dependsOn edges pointing at packages it excluded as dev + // dependencies (e.g. analyzers stripped by -ed), which dangle once the component is gone + // (upstream bug CycloneDX/cyclonedx-dotnet#761, still reproducing in 6.2.0). Drop those so + // the graph only references components actually present in the SBOM. + PruneDanglingDependencyEdges(merged); + + var sbomJson = merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(outputDirectory / $"{finalId}.{version}.cdx.json", sbomJson); + + // Embed the SBOM inside the shipped .nupkg so it travels with the package, in addition to + // the standalone copy written above (which CI publishes as the SBOM artifact) - belt and + // suspenders: consumers who only ever see the package still get its bill of materials. + if (finalNupkg is not null) + EmbedSbomInPackage(finalNupkg, sbomJson); + } + + // The path inside the .nupkg where the CycloneDX SBOM is embedded. Mirrors the _manifest/ + // layout Microsoft.Sbom.Targets uses for its SPDX manifest, but keeps CycloneDX's recognised + // *.cdx.json filename so tools that scan for that pattern still find it once unpacked. + const string EmbeddedSbomEntryPath = "_manifest/cyclonedx/bom.cdx.json"; + + // Adds the generated SBOM as a new part inside the shipped package. Must run before the package + // is signed - a NuGet signature covers the whole archive, so adding a part afterwards would + // invalidate it. That holds here: these packages are signed server-side by nuget.org on push, + // which happens after this build step. + static void EmbedSbomInPackage(AbsolutePath nupkgPath, string sbomJson) + { + using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.ReadWrite); + using var zip = new ZipArchive(file, ZipArchiveMode.Update); + + if (zip.Entries.Any(e => e.FullName.EndsWith(".signature.p7s", StringComparison.OrdinalIgnoreCase))) + { + Warning($"SBOM: '{nupkgPath.Name}' is already signed - skipping embed so its signature stays valid."); + return; + } + + // Re-embedding (e.g. a re-run over the same output) should replace, not stack duplicates. + zip.GetEntry(EmbeddedSbomEntryPath)?.Delete(); + using (var entryStream = zip.CreateEntry(EmbeddedSbomEntryPath).Open()) + using (var writer = new StreamWriter(entryStream)) + writer.Write(sbomJson); + + EnsureJsonContentTypeRegistered(zip); + } + + // A .nupkg is an OPC package: every part's extension must be declared in [Content_Types].xml or + // strict OPC readers - including NuGet's own signature verification - reject the package. The + // SBOM is a .json part, so register that extension before (or as) we add it. + static void EnsureJsonContentTypeRegistered(ZipArchive zip) + { + const string contentTypesEntryName = "[Content_Types].xml"; + XNamespace ns = "http://schemas.openxmlformats.org/package/2006/content-types"; + + var entry = zip.GetEntry(contentTypesEntryName); + if (entry is null) + return; // not a well-formed OPC package; don't fabricate one + + XDocument doc; + using (var read = entry.Open()) + doc = XDocument.Load(read); + + var alreadyRegistered = doc.Root!.Elements(ns + "Default") + .Any(d => string.Equals((string?)d.Attribute("Extension"), "json", StringComparison.OrdinalIgnoreCase)); + if (alreadyRegistered) + return; + + doc.Root.Add(new XElement(ns + "Default", + new XAttribute("Extension", "json"), + new XAttribute("ContentType", "application/json"))); + + entry.Delete(); + using var write = zip.CreateEntry(contentTypesEntryName).Open(); + doc.Save(write); + } + + static void PruneDanglingDependencyEdges(JsonObject merged) + { + var deps = merged["dependencies"]?.AsArray(); + if (deps is null) + return; + + var known = new HashSet(); + var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue(); + if (rootRef is not null) + known.Add(rootRef); + foreach (var component in merged["components"]?.AsArray() ?? new JsonArray()) + { + if (component?["bom-ref"]?.GetValue() is { } bomRef) + known.Add(bomRef); + if (component?["purl"]?.GetValue() is { } purl) + known.Add(purl); + } + + foreach (var node in deps.OfType()) + { + var dependsOn = node["dependsOn"]?.AsArray(); + if (dependsOn is null) + continue; + var kept = new JsonArray(); + foreach (var edge in dependsOn) + if (known.Contains(edge!.GetValue())) + kept.Add(edge.GetValue()); + node["dependsOn"] = kept; + } + } + + static void MergeDependencyGraph(JsonObject target, JsonArray incoming) + { + var targetDeps = target["dependencies"]?.AsArray() ?? (JsonArray)(target["dependencies"] = new JsonArray()); + var byRef = targetDeps.OfType().ToDictionary(d => d["ref"]!.GetValue()); + + foreach (var node in incoming.OfType()) + { + var nodeRef = node["ref"]!.GetValue(); + var dependsOn = node["dependsOn"]?.AsArray().Select(x => x!.GetValue()) ?? Enumerable.Empty(); + + if (!byRef.TryGetValue(nodeRef, out var existing)) + { + existing = node.DeepClone().AsObject(); + targetDeps.Add(existing); + byRef[nodeRef] = existing; + } + + var existingDependsOn = existing["dependsOn"]?.AsArray() ?? (JsonArray)(existing["dependsOn"] = new JsonArray()); + var seen = existingDependsOn.Select(x => x!.GetValue()).ToHashSet(); + foreach (var dep in dependsOn) + if (seen.Add(dep)) + existingDependsOn.Add(dep); + } + } + + // Scans /**/webapp/package.json for production "dependencies" (deliberately + // ignoring devDependencies, which never ship) and adds them - plus their transitive + // dependencies, walked through the installed node_modules - as fully-formed components: + // bom-ref, resolved version, license, and dependency-graph edges, matching the shape of the + // NuGet components cyclonedx-dotnet emits so npm packages aren't second-class SBOM entries. + // Versions come from the actually-installed node_modules (same rationale as reading + // project.assets.json rather than trusting floating ranges). + static void AddNpmComponents(JsonObject merged, HashSet seenComponentKeys, AbsolutePath projectDir, + string? rootRef) + { + foreach (string packageJsonPath in projectDir.GlobFiles("**/webapp/package.json")) + { + var packageJson = JsonNode.Parse(File.ReadAllText(packageJsonPath))!.AsObject(); + var nodeModules = ((AbsolutePath)packageJsonPath).Parent / "node_modules"; + var dependencies = packageJson["dependencies"]?.AsObject() ?? new JsonObject(); + + // The webapp is bundled into the shipped package, so its direct production + // dependencies are direct dependencies of the final NuGet package. + foreach (var (name, rangeNode) in dependencies) + { + if (IsTypeOnlyPackage(name)) + continue; + var purl = AddNpmComponentTree(merged, seenComponentKeys, nodeModules, name, + rangeNode!.GetValue(), nodeModules); + if (rootRef is not null) + AddDependsOn(merged, rootRef, purl); + } + } + } + + // Adds the component for (name, range) and, recursively, everything it depends on, returning + // its purl. The component/graph node/subtree are materialised only the first time a purl is + // seen (which also breaks any dependency cycles); repeat encounters just return the purl so + // the caller can still record its own edge to it. + static string AddNpmComponentTree(JsonObject merged, HashSet seenComponentKeys, + AbsolutePath topLevelNodeModules, string name, string declaredRange, AbsolutePath parentNodeModules) + { + var (purl, componentVersion, installedDir) = + ResolveNpmComponent(name, declaredRange, parentNodeModules, topLevelNodeModules); + + if (!seenComponentKeys.Add(purl)) + return purl; + + var installed = installedDir is not null && File.Exists(installedDir / "package.json") + ? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!.AsObject() + : null; + + var component = new JsonObject + { + ["type"] = "library", + ["bom-ref"] = purl, + ["name"] = name, + ["version"] = componentVersion, + ["purl"] = purl + }; + // No hashes: npm's verifiable hashes live in the (binary bun) lockfile, not the installed + // tree, and a hash of the unpacked directory wouldn't be checkable against a registry. + var licenses = installed is null ? null : BuildLicenses(installed); + if (licenses is not null) + component["licenses"] = licenses; + + var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray()); + target.Add(component); + + var node = new JsonObject { ["ref"] = purl, ["dependsOn"] = new JsonArray() }; + (merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray())).Add(node); + + var childDeps = installed?["dependencies"]?.AsObject() ?? new JsonObject(); + var childNodeModules = installedDir is not null ? installedDir / "node_modules" : parentNodeModules; + var dependsOn = node["dependsOn"]!.AsArray(); + foreach (var (childName, childRange) in childDeps) + { + if (IsTypeOnlyPackage(childName)) + continue; + var childPurl = AddNpmComponentTree(merged, seenComponentKeys, topLevelNodeModules, childName, + childRange!.GetValue(), childNodeModules); + dependsOn.Add(childPurl); + } + + return purl; + } + + // @types/* packages are TypeScript declaration stubs: esbuild strips them at build time, so + // they're never part of the shipped bytes and don't belong in a scope-of-delivery SBOM. + static bool IsTypeOnlyPackage(string name) => name.StartsWith("@types/", StringComparison.Ordinal); + + static void AddDependsOn(JsonObject merged, string fromRef, string toPurl) + { + var deps = merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray()); + var node = deps.OfType().FirstOrDefault(d => d["ref"]?.GetValue() == fromRef); + if (node is null) + { + node = new JsonObject { ["ref"] = fromRef, ["dependsOn"] = new JsonArray() }; + deps.Add(node); + } + + var dependsOn = node["dependsOn"]?.AsArray() ?? (JsonArray)(node["dependsOn"] = new JsonArray()); + if (!dependsOn.Any(x => x!.GetValue() == toPurl)) + dependsOn.Add(toPurl); + } + + static JsonArray? BuildLicenses(JsonObject installedPackageJson) + { + // Modern npm: "license" is an SPDX id or expression. Legacy: "license"/"licenses" objects. + if (installedPackageJson["license"] is JsonValue licenseValue && licenseValue.TryGetValue(out string? spdx)) + { + var licenses = SpdxToLicenses(spdx); + if (licenses is not null) + return licenses; + } + + var legacy = (installedPackageJson["license"] as JsonObject)?["type"]?.GetValue() + ?? (installedPackageJson["licenses"] as JsonArray)?.OfType() + .FirstOrDefault()?["type"]?.GetValue(); + return legacy is null + ? null + : new JsonArray(new JsonObject { ["license"] = new JsonObject { ["name"] = legacy } }); + } + + // Turns an SPDX string into a CycloneDX licenses array: a single license id becomes a + // {license:{id}} entry, a compound SPDX expression becomes an {expression} entry. + static JsonArray? SpdxToLicenses(string? spdx) + { + if (string.IsNullOrWhiteSpace(spdx)) + return null; + var isExpression = spdx.IndexOf(" OR ", StringComparison.Ordinal) >= 0 + || spdx.IndexOf(" AND ", StringComparison.Ordinal) >= 0 + || spdx.IndexOf(" WITH ", StringComparison.Ordinal) >= 0; + return new JsonArray(isExpression + ? new JsonObject { ["expression"] = spdx } + : new JsonObject { ["license"] = new JsonObject { ["id"] = spdx } }); + } + + static (string Purl, string Version, AbsolutePath? InstalledDir) ResolveNpmComponent( + string name, string declaredRange, AbsolutePath parentNodeModules, AbsolutePath topLevelNodeModules) + { + // npm/bun hoist most packages to the top level but may nest a conflicting version under + // the depending package, so prefer the nested copy and fall back to the hoisted one. + var installedDir = new[] { parentNodeModules / name, topLevelNodeModules / name } + .FirstOrDefault(d => File.Exists(d / "package.json")); + + if (declaredRange.StartsWith("github:") || declaredRange.StartsWith("git") || declaredRange.Contains("://")) + { + if (TryParseGitHubDependency(declaredRange, out var owner, out var repo, out var reference)) + return ($"pkg:github/{owner}/{repo}@{reference}", reference, installedDir); + + // A non-GitHub git/URL specifier (GitLab/Bitbucket, a raw tarball URL, ...). We have no + // provider-specific purl for it, so degrade to a generic component - preferring the + // version installed on disk - rather than throwing and failing the whole release over a + // single dependency we can't classify precisely. + var resolvedVersion = installedDir is not null + ? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue() + : null; + resolvedVersion ??= declaredRange; + Warning($"SBOM: npm dependency '{name}' uses an unrecognised git/URL specifier '{declaredRange}' - recording it as a generic component with version '{resolvedVersion}'."); + return ($"pkg:generic/{EncodeNpmName(name)}@{resolvedVersion}", resolvedVersion, installedDir); + } + + if (installedDir is null) + { + Warning($"SBOM: npm dependency '{name}' isn't installed near {parentNodeModules} - recording its declared range '{declaredRange}' instead of a resolved version."); + return ($"pkg:npm/{EncodeNpmName(name)}@{declaredRange}", declaredRange, null); + } + + var installedVersion = JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue(); + if (installedVersion is null) + { + // package.json without a "version" is valid for private packages; don't let it NRE. + Warning($"SBOM: npm dependency '{name}' installed at {installedDir} has no version in its package.json - recording its declared range '{declaredRange}' instead."); + installedVersion = declaredRange; + } + return ($"pkg:npm/{EncodeNpmName(name)}@{installedVersion}", installedVersion, installedDir); + } + + static string EncodeNpmName(string name) => name.StartsWith("@") ? $"%40{name[1..]}" : name; + + static bool TryParseGitHubDependency(string spec, out string owner, out string repo, out string reference) + { + owner = repo = ""; + var hashIndex = spec.IndexOf('#'); + reference = hashIndex >= 0 ? spec[(hashIndex + 1)..] : "HEAD"; + var withoutRef = hashIndex >= 0 ? spec[..hashIndex] : spec; + + var match = Regex.Match(withoutRef, @"github(?:\.com)?[:/]+([^/]+)/([^/#]+?)(?:\.git)?$"); + if (!match.Success) + return false; + owner = match.Groups[1].Value; + repo = match.Groups[2].Value; + return true; + } + + // Prefer purl (or bom-ref) as the identity; both encode version. Fall back to name+version so + // distinct versions of the same package aren't collapsed into one, and to a fresh GUID when + // there's nothing identifiable to key on (treating it as unique rather than deduping blindly). + static string ComponentKey(JsonNode? component) => + component?["purl"]?.GetValue() + ?? component?["bom-ref"]?.GetValue() + ?? (component?["name"]?.GetValue() is { } name + ? $"{name}@{component?["version"]?.GetValue()}" + : Guid.NewGuid().ToString()); + + // Fills in the root component with the publisher, licence, description and repository details + // from the shipped .nuspec - cyclonedx-dotnet only emits type/name/version, which is far short + // of the manufacturer/provenance information a CRA-facing SBOM is expected to carry. + static void EnrichRootComponent(JsonObject merged, NuspecMetadata meta) + { + var component = merged["metadata"]?["component"]?.AsObject(); + if (component is null) + return; + + // These are shipped libraries, not applications (cyclonedx-dotnet's default type). + component["type"] = "library"; + component["purl"] = $"pkg:nuget/{meta.Id}@{meta.Version}"; + if (meta.Description is not null) + component["description"] = meta.Description; + if (meta.Copyright is not null) + component["copyright"] = meta.Copyright; + + JsonObject? supplier = null; + if (meta.Authors is not null) + { + component["publisher"] = meta.Authors; + component["author"] = meta.Authors; + supplier = new JsonObject { ["name"] = meta.Authors }; + if (meta.ProjectUrl is not null) + supplier["url"] = new JsonArray(meta.ProjectUrl); + component["supplier"] = supplier; + } + + var licenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId); + // A file license has no SPDX id; record it by name rather than emitting an invalid id. + if (licenses is null && meta.LicenseFile is not null) + licenses = new JsonArray(new JsonObject + { ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } }); + if (licenses is not null) + component["licenses"] = licenses; + + var externalReferences = new JsonArray(); + if (meta.ProjectUrl is not null) + externalReferences.Add(new JsonObject { ["url"] = meta.ProjectUrl, ["type"] = "website" }); + if (meta.RepositoryUrl is not null) + externalReferences.Add(new JsonObject { ["url"] = meta.RepositoryUrl, ["type"] = "vcs" }); + if (externalReferences.Count > 0) + component["externalReferences"] = externalReferences; + + // Also record the manufacturer at the document level (the entity that supplied the BOM). + if (supplier is not null && merged["metadata"] is JsonObject metadata) + metadata["supplier"] = supplier.DeepClone(); + } + + // Cross-checks what the package actually ships against the components derived from the + // dependency graph. Avalonia's own merged modules (Numerge folds several projects' assemblies + // into one package) are recorded as manufacturer-supplied components with a verifiable SHA-512 + // of the shipped bytes; any third-party binary that no restored dependency accounts for is + // added and flagged, so a future bundling regression can't silently escape the SBOM. + // + // These shipped binaries are *constituents* of the package, not dependencies of it: the package + // is made up of them (Component C bundles D and E, in CycloneDX's terms), so they're modelled as + // a CycloneDX "assembly" via a top-level `compositions` entry rather than with a dependsOn edge + // from the root - "contains" and "depends on" are different relationships, and an assembly does + // not imply a dependency. They stay flat top-level components (not nested subcomponents) so their + // per-assembly hashes remain visible to scanners that ignore nested components. + static void AddPackageContentComponents(JsonObject merged, HashSet seenComponentKeys, + string nupkgPath, string finalId, List constituentProjectIds, NuspecMetadata meta) + { + var productNames = new HashSet(constituentProjectIds, StringComparer.OrdinalIgnoreCase) { finalId }; + var representedNames = (merged["components"]?.AsArray() ?? new JsonArray()) + .Select(c => c?["name"]?.GetValue()) + .Where(n => n is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase)!; + + var supplier = meta.Authors is null ? null : new JsonObject { ["name"] = meta.Authors }; + var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray()); + var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue(); + + // The bundled first-party assemblies share the package's licence; cyclonedx-dotnet only puts + // it on the root component, so carry it onto them too (same derivation as EnrichRootComponent). + var productLicenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId); + if (productLicenses is null && meta.LicenseFile is not null) + productLicenses = new JsonArray(new JsonObject + { ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } }); + + // bom-refs of every shipped binary added below; declared as a complete assembly at the end. + var assemblyRefs = new List(); + + using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read); + using var zip = new ZipArchive(file, ZipArchiveMode.Read); + foreach (var entry in zip.Entries) + { + var path = entry.FullName; + // Reference assemblies under ref/ are compile-time surface, not shipped runtime code; + // the real implementation lives under lib/ and is scanned there. + if (!IsShippedBinary(path) || path.StartsWith("ref/", StringComparison.OrdinalIgnoreCase)) + continue; + + var bytes = ReadEntry(entry); + var assemblyName = TryReadAssemblyName(bytes, out var assemblyVersion); + var simpleName = assemblyName ?? Path.GetFileNameWithoutExtension(path); + + // Third-party binaries already represented by a NuGet/npm component need no duplicate. + if (assemblyName is not null && representedNames.Contains(simpleName)) + continue; + + var isProduct = productNames.Contains(simpleName) + || simpleName.StartsWith("Avalonia.", StringComparison.OrdinalIgnoreCase) + || simpleName.Equals("Avalonia", StringComparison.OrdinalIgnoreCase); + + // The package's primary assembly is the root component itself - don't list it as its own subcomponent. + if (isProduct && simpleName.Equals(finalId, StringComparison.OrdinalIgnoreCase)) + continue; + + var version = assemblyVersion ?? meta.Version; + var bomRef = $"binary:{simpleName}@{version}"; + if (!seenComponentKeys.Add(bomRef)) + continue; + + if (!isProduct) + Warning($"SBOM: package '{finalId}' ships '{path}' ({simpleName}) which no restored dependency accounts for - added from package contents, please verify its provenance."); + + var component = new JsonObject + { + ["type"] = "library", + ["bom-ref"] = bomRef, + ["name"] = simpleName, + ["version"] = version, + ["scope"] = "required", + ["hashes"] = new JsonArray(new JsonObject + { + ["alg"] = "SHA-512", + ["content"] = Convert.ToHexString(SHA512.HashData(bytes)) + }), + ["properties"] = new JsonArray(new JsonObject + { + ["name"] = "avalonia:packagePath", + ["value"] = path + }) + }; + if (isProduct) + { + if (supplier is not null) + component["supplier"] = supplier.DeepClone(); + if (productLicenses is not null) + component["licenses"] = productLicenses.DeepClone(); + } + target.Add(component); + + assemblyRefs.Add(bomRef); + } + + // Declare the package's assembly: the root component consists of exactly these bundled + // binaries. We've read every shipped binary out of the .nupkg, so the enumeration is + // complete (third-party dependencies remain in the dependency graph, as they should). + if (assemblyRefs.Count > 0) + { + var assemblies = new JsonArray(); + if (rootRef is not null) + assemblies.Add(rootRef); + foreach (var bomRef in assemblyRefs) + assemblies.Add(bomRef); + + var compositions = merged["compositions"]?.AsArray() + ?? (JsonArray)(merged["compositions"] = new JsonArray()); + compositions.Add(new JsonObject + { + ["aggregate"] = "complete", + ["assemblies"] = assemblies + }); + } + } + + static bool IsShippedBinary(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext is ".dll" or ".so" or ".dylib" or ".wasm" or ".node" or ".a"; + } + + static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + // Returns the managed assembly's simple name (and version), or null for native / non-managed binaries. + static string? TryReadAssemblyName(byte[] bytes, out string? version) + { + version = null; + try + { + using var pe = new PEReader(new MemoryStream(bytes)); + if (!pe.HasMetadata) + return null; + var reader = pe.GetMetadataReader(); + if (!reader.IsAssembly) + return null; + var assembly = reader.GetAssemblyDefinition(); + version = assembly.Version.ToString(); + return reader.GetString(assembly.Name); + } + catch (BadImageFormatException) + { + return null; + } + } + + class NuspecMetadata + { + public string Id = ""; + public string Version = ""; + public string? Authors; + public string? LicenseId; + public string? LicenseExpression; + public string? LicenseFile; + public string? ProjectUrl; + public string? RepositoryUrl; + public string? Description; + public string? Copyright; + } + + static NuspecMetadata ReadNuspecMetadata(string nupkgPath) + { + using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read); + using var zip = new ZipArchive(file, ZipArchiveMode.Read); + var nuspecEntry = zip.Entries.First(e => e.FullName.EndsWith(".nuspec", StringComparison.Ordinal) && e.FullName == e.Name); + using var nuspecStream = nuspecEntry.Open(); + var metadata = XDocument.Load(nuspecStream).Root! + .Elements().First(x => x.Name.LocalName == "metadata"); + + string? Value(string name) => metadata.Elements().FirstOrDefault(x => x.Name.LocalName == name)?.Value; + var license = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "license"); + var repository = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "repository"); + + return new NuspecMetadata + { + Id = Value("id") ?? "", + Version = Value("version") ?? "", + Authors = Value("authors"), + // A nuspec is either type="expression" (an SPDX expression) or type="file" + // (a path to a bundled licence file); only the former is a valid SPDX id/expression. + LicenseExpression = license?.Attribute("type")?.Value == "expression" ? license.Value : null, + LicenseFile = license?.Attribute("type")?.Value == "file" ? license.Value : null, + LicenseId = license?.Attribute("type")?.Value is "expression" or "file" ? null : license?.Value, + ProjectUrl = Value("projectUrl"), + RepositoryUrl = repository?.Attribute("url")?.Value, + Description = Value("description"), + Copyright = Value("copyright") + }; + } + + public static string ReadPackageId(string nupkgPath) => ReadNuspecMetadata(nupkgPath).Id; +} diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj index c58a2b894b..045f652291 100644 --- a/nukebuild/_build.csproj +++ b/nukebuild/_build.csproj @@ -13,13 +13,13 @@ - - + + diff --git a/packages/Avalonia/AvaloniaBuildTasks.targets b/packages/Avalonia/AvaloniaBuildTasks.targets index d3750935f9..ec1ef7b46c 100644 --- a/packages/Avalonia/AvaloniaBuildTasks.targets +++ b/packages/Avalonia/AvaloniaBuildTasks.targets @@ -39,6 +39,23 @@ Condition="'$(ApplicationIcon)' != ''" /> + + + + + + + <_AvaloniaResourceXamlOnly Include="@(AvaloniaResource)" + Condition="'%(Extension)' == '.xaml' OR '%(Extension)' == '.axaml'" + Exclude="@(AvaloniaXaml)" /> + + + + + @(AvaloniaResource->'%(FullPath)', ',') + + + diff --git a/readme.md b/readme.md index 9ed6c59f90..e291498703 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ Considered by many to be the spiritual successor to WPF, Avalonia UI provides a familiar, modern development experience for XAML developers creating cross-platform applications. While Avalonia UI is [similar to WPF](https://docs.avaloniaui.net/docs/next/get-started/wpf/), it isn't a 1:1 copy, and you'll find plenty of improvements. -For those seeking a cross-platform WPF, we have created [Avalonia XPF]([https://avaloniaui.net/xpf](https://avaloniaui.net/xpf?utm_source=github&utm_medium=referral&utm_content=readme_link)), enabling WPF applications to run on macOS and Linux with little to no code changes. Avalonia XPF is a commercial product and is licensed per-app, per-platform. +For those seeking a cross-platform WPF, we have created [Avalonia XPF](https://avaloniaui.net/xpf?utm_source=github&utm_medium=referral&utm_content=readme_link), enabling WPF applications to run on macOS and Linux with little to no code changes. Avalonia XPF is a commercial product and is licensed per-app, per-platform. ### Blog To see the latest announcements and read about the state of Avalonia, check out the [Avalonia UI Blog](https://www.avaloniaui.net/blog?utm_source=github&utm_medium=referral&utm_content=readme_link). diff --git a/samples/AppWithoutLifetime/Program.cs b/samples/AppWithoutLifetime/Program.cs index 60ab32824e..80be3f76bd 100644 --- a/samples/AppWithoutLifetime/Program.cs +++ b/samples/AppWithoutLifetime/Program.cs @@ -24,6 +24,8 @@ class Program public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() +#if DEBUG .WithDeveloperTools() +#endif .LogToTrace(); } diff --git a/samples/BindingDemo/App.xaml.cs b/samples/BindingDemo/App.xaml.cs index e76f61ac63..08ae7d183f 100644 --- a/samples/BindingDemo/App.xaml.cs +++ b/samples/BindingDemo/App.xaml.cs @@ -24,7 +24,10 @@ namespace BindingDemo public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() + .WithDataAnnotationsValidation() +#if DEBUG .WithDeveloperTools() +#endif .LogToTrace(); } } diff --git a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj index a630799c05..978eb361c4 100644 --- a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj +++ b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj @@ -14,16 +14,6 @@ - - True - True - - - - - - - diff --git a/samples/ControlCatalog.Android/environment.device.txt b/samples/ControlCatalog.Android/environment.device.txt deleted file mode 100644 index 107d68ca1b..0000000000 --- a/samples/ControlCatalog.Android/environment.device.txt +++ /dev/null @@ -1 +0,0 @@ -DOTNET_DiagnosticPorts=127.0.0.1:9000,suspend diff --git a/samples/ControlCatalog.Android/environment.emulator.txt b/samples/ControlCatalog.Android/environment.emulator.txt deleted file mode 100644 index 299a0ec30b..0000000000 --- a/samples/ControlCatalog.Android/environment.emulator.txt +++ /dev/null @@ -1 +0,0 @@ -DOTNET_DiagnosticPorts=10.0.2.2:9001,suspend diff --git a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj index 22a091449f..779e9b3462 100644 --- a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj +++ b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj @@ -21,13 +21,12 @@ + - - diff --git a/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs b/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs index 523503cc38..e02b1b7428 100644 --- a/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs +++ b/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs @@ -21,10 +21,11 @@ public class EmbedSampleGtk : INativeDemoControl } var control = createDefault(); - var nodes = Path.GetFullPath(Path.Combine(typeof(EmbedSample).Assembly.GetModules()[0].FullyQualifiedName, - "..", "NativeControls", "Gtk", "nodes.mp4")); + var nodesFile = Path.Combine(AppContext.BaseDirectory, "NativeControls", "Gtk", "nodes.mp4"); + nodesFile = Path.GetFullPath(nodesFile); + _mplayer = Process.Start(new ProcessStartInfo("mplayer", - $"-vo x11 -zoom -loop 0 -wid {control.Handle.ToInt64()} \"{nodes}\"") + $"-vo x11 -zoom -loop 0 -wid {control.Handle.ToInt64()} \"{nodesFile}\"") { UseShellExecute = false, diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs index b761ce0972..cd8752825a 100644 --- a/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs +++ b/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs @@ -1,29 +1,57 @@ using System; +using System.Runtime.InteropServices; +using Avalonia.Controls.Platform; using Avalonia.Platform; -using Avalonia.Threading; - using ControlCatalog.Pages; -using MonoMac.Foundation; -using MonoMac.WebKit; - namespace ControlCatalog.Desktop; public class EmbedSampleMac : INativeDemoControl { + private static bool? s_webKitLoaded; + public IPlatformHandle CreateControl(bool isSecond, IPlatformHandle parent, Func createDefault) { - // Note: We are using MonoMac for example purposes - // It shouldn't be used in production apps - MacHelper.EnsureInitialized(); + s_webKitLoaded ??= NativeLibrary.TryLoad("/System/Library/Frameworks/WebKit.framework/WebKit", out _); - var webView = new WebView(); - Dispatcher.UIThread.Post(() => + if (s_webKitLoaded is false) { - webView.MainFrame.LoadRequest(new NSUrlRequest(new NSUrl( - isSecond ? "https://bing.com" : "https://google.com/"))); - }); + return createDefault(); + } + + // alloc + var webView = ObjC.MsgSend(ObjC.GetClass("WKWebView"), ObjC.GetUid("alloc")); + // init + webView = ObjC.MsgSend(webView, ObjC.GetUid("init")); + + // ns url + var urlNsString = ObjC.CreateString(isSecond ? "https://bing.com" : "https://google.com/"); + var url = ObjC.MsgSend( + ObjC.GetClass("NSURL"), ObjC.GetUid("URLWithString:"), + urlNsString); + + // ns url request + var request = ObjC.MsgSend(ObjC.GetClass("NSURLRequest"), ObjC.GetUid("requestWithURL:"), url); + + // load request + ObjC.MsgSend(webView, ObjC.GetUid("loadRequest:"), request); + return new MacOSViewHandle(webView); } + + private sealed class MacOSViewHandle(IntPtr view) : INativeControlHostDestroyableControlHandle + { + public IntPtr Handle { get; private set; } = view; + public string HandleDescriptor => "NSView"; + + public void Destroy() + { + if (Handle != IntPtr.Zero) + { + ObjC.MsgSend(Handle, ObjC.GetUid("release")); + Handle = IntPtr.Zero; + } + } + } } diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs deleted file mode 100644 index 01325a5fa9..0000000000 --- a/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -using Avalonia.Controls.Platform; -using MonoMac.AppKit; - -namespace ControlCatalog.Desktop; - -internal class MacHelper -{ - private static bool _isInitialized; - - public static void EnsureInitialized() - { - if (_isInitialized) - return; - _isInitialized = true; - NSApplication.Init(); - } -} - -internal class MacOSViewHandle : INativeControlHostDestroyableControlHandle -{ - private NSView? _view; - - public MacOSViewHandle(NSView view) - { - _view = view; - } - - public IntPtr Handle => _view?.Handle ?? IntPtr.Zero; - public string HandleDescriptor => "NSView"; - - public void Destroy() - { - _view?.Dispose(); - _view = null; - } -} diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs new file mode 100644 index 0000000000..3ef5c77ff7 --- /dev/null +++ b/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia.Platform.Interop; + +namespace ControlCatalog.Desktop; + +internal static partial class ObjC +{ + private const string LibObjC = "/usr/lib/libobjc.dylib"; + + public static IntPtr CreateString(string value) + { + using var utf8 = new Utf8Buffer(value); + return MsgSend(GetClass("NSString"), GetUid("stringWithUTF8String:"), utf8); + } + + [LibraryImport(LibObjC, EntryPoint = "objc_getClass", StringMarshalling = StringMarshalling.Utf8)] + internal static partial IntPtr GetClass(string name); + + [LibraryImport(LibObjC, EntryPoint = "sel_getUid", StringMarshalling = StringMarshalling.Utf8)] + internal static partial IntPtr GetUid(string selector); + + [LibraryImport(LibObjC, EntryPoint = "objc_msgSend")] + internal static partial IntPtr MsgSend(IntPtr receiver, IntPtr selector); + + [LibraryImport(LibObjC, EntryPoint = "objc_msgSend")] + internal static partial IntPtr MsgSend(IntPtr receiver, IntPtr selector, IntPtr arg); +} diff --git a/samples/ControlCatalog.Desktop/Program.cs b/samples/ControlCatalog.Desktop/Program.cs index 819072db18..7cdee5056a 100644 --- a/samples/ControlCatalog.Desktop/Program.cs +++ b/samples/ControlCatalog.Desktop/Program.cs @@ -142,6 +142,8 @@ namespace ControlCatalog.Desktop public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() + .WithDataAnnotationsValidation() + .UseWaylandWithFallback() .With(new X11PlatformOptions { EnableMultiTouch = true, @@ -162,7 +164,9 @@ namespace ControlCatalog.Desktop }) .UseSkia() .WithInterFont() +#if DEBUG .WithDeveloperTools() +#endif .AfterSetup(builder => { EmbedSample.Implementation = OperatingSystem.IsWindows() ? new EmbedSampleWin() diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 84fbadf525..4a3abb8a30 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -60,6 +60,9 @@ + diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index c8dc7fc5d6..b826a3ae3e 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -42,12 +42,6 @@ - - - MSBuild:Compile - - - diff --git a/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs b/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs new file mode 100644 index 0000000000..c5c3e0e2ed --- /dev/null +++ b/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace ControlCatalog.Converter +{ + internal sealed class FlexDemoNumberToThicknessConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int x && targetType.IsAssignableFrom(typeof(Thickness))) + { + var y = 16 + 2 * ((x * 5) % 9); + return new Thickness(2 * y, y); + } + + throw new NotSupportedException(); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); + } +} diff --git a/samples/ControlCatalog/Converter/GdpToBrushConverter.cs b/samples/ControlCatalog/Converter/GdpToBrushConverter.cs new file mode 100644 index 0000000000..d5d6f5cd07 --- /dev/null +++ b/samples/ControlCatalog/Converter/GdpToBrushConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Avalonia.Media.Immutable; + +namespace ControlCatalog.Converter; + +public sealed class GdpToBrushConverter : IValueConverter +{ + private readonly ImmutableSolidColorBrush _orangeBrush = new(Colors.Orange, 0.6); + private readonly ImmutableSolidColorBrush _yellowBrush = new(Colors.Yellow, 0.6); + private readonly ImmutableSolidColorBrush _greenBrush = new(Colors.LightGreen, 0.6); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not int gdp) + return AvaloniaProperty.UnsetValue; + + return gdp switch + { + <= 5000 => _orangeBrush, + <= 10000 => _yellowBrush, + _ => _greenBrush + }; + } + + object IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs b/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs new file mode 100644 index 0000000000..e9f976f6f3 --- /dev/null +++ b/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; + +namespace ControlCatalog.Converter; + +public sealed class TableViewColumnWidthConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool useStarSize && + parameter is string stringParameter && + double.TryParse(stringParameter, NumberStyles.Number, CultureInfo.InvariantCulture, out var baseWidth)) + { + return useStarSize ? + new GridLength(baseWidth, GridUnitType.Star) : + new GridLength(baseWidth * 100, GridUnitType.Pixel); + } + + return AvaloniaProperty.UnsetValue; + } + + object? IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 94f6376896..84f504f4d9 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -159,4 +159,4 @@ - + \ No newline at end of file diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index 4c0118872b..308b29c572 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -12,6 +12,8 @@ CanResize="{Binding CanResize}" CanMinimize="{Binding CanMinimize}" CanMaximize="{Binding CanMaximize}" + WindowDrawnDecorations.TitleBarDecorations="{Binding TitleBarDecorations}" + Win32Properties.WindowCornerPreference="{Binding Win32WindowCornerPreference}" x:Class="ControlCatalog.MainWindow" WindowState="{Binding WindowState, Mode=TwoWay}" x:DataType="vm:MainWindowViewModel"> diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs index 295aa9d139..1624fc563f 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -102,8 +102,8 @@ namespace ControlCatalog.Pages return String.Format("{0} ({1})", parts.ToArray()); }); var binding = new MultiBinding { Converter = converter }; - binding.Bindings.Add(new Binding("Name")); - binding.Bindings.Add(new Binding("Abbreviation")); + binding.Bindings.Add(CompiledBinding.Create(s => s.Name)); + binding.Bindings.Add(CompiledBinding.Create(s => s.Abbreviation)); MultiBindingBox.ValueMemberBinding = binding; diff --git a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml index 769dbc0dac..c00af6c801 100644 --- a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml @@ -49,6 +49,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/CalendarPage.xaml b/samples/ControlCatalog/Pages/CalendarPage.xaml index 99727e9124..44ab767dea 100644 --- a/samples/ControlCatalog/Pages/CalendarPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarPage.xaml @@ -48,6 +48,17 @@ + + + + + # + + + diff --git a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs index bc8be3ea87..16f1ba0ddc 100644 --- a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs @@ -5,6 +5,7 @@ using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; @@ -130,7 +131,7 @@ public partial class CareCompanionAppPage : UserControl }; pager.Bind(PipsPager.SelectedPageIndexProperty, - new Avalonia.Data.Binding("SelectedIndex") { Source = carousel, Mode = Avalonia.Data.BindingMode.TwoWay }); + CompiledBinding.Create(c => c.SelectedIndex, carousel, mode: BindingMode.TwoWay)); return pager; } diff --git a/samples/ControlCatalog/Pages/CompositionPage.axaml b/samples/ControlCatalog/Pages/CompositionPage.axaml index a3e9b047a8..0e4fcf2ab6 100644 --- a/samples/ControlCatalog/Pages/CompositionPage.axaml +++ b/samples/ControlCatalog/Pages/CompositionPage.axaml @@ -1,61 +1,204 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Resize me - - - - - - - - - - - - - - - - - - Precise dirty rects - - - - + + + + + + + + + + + + + + + + + + + + + + + + + Resize me + + + + + + + + + + + + + + + + + + + Precise dirty rects + + + + + + + + + + + + + + + + + + + + + + + Content="Show XAML only Notification" Click="ShowNotification" /> diff --git a/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs b/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs index 5ff67e10dc..cc1ad3481c 100644 --- a/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; +using Avalonia.Interactivity; using ControlCatalog.ViewModels; namespace ControlCatalog.Pages @@ -25,9 +26,14 @@ namespace ControlCatalog.Pages _viewModel.NotificationManager = new WindowNotificationManager(TopLevel.GetTopLevel(this)!); } - public void NotificationOnClick() + private void ShowNotification(object? sender, RoutedEventArgs e) { - ControlNotifications.Show("Notification clicked"); + ControlNotifications.Show(new Notification + { + OnClick = () => ControlNotifications.Show("Notification clicked"), + Title = "Title", + Message = "Message" + }); } } } diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs index db64fba0ef..0254fbaa55 100644 --- a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs @@ -71,7 +71,7 @@ namespace ControlCatalog.Pages if (_spinnerLocations == null) { _spinnerLocations = new List(); - foreach (Location value in Enum.GetValues(typeof(Location))) + foreach (var value in Enum.GetValues()) { _spinnerLocations.Add(value); } diff --git a/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml new file mode 100644 index 0000000000..a6213558cb --- /dev/null +++ b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs new file mode 100644 index 0000000000..8f3e6f4b48 --- /dev/null +++ b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs @@ -0,0 +1,30 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; +using ControlCatalog.ViewModels; + +namespace ControlCatalog.Pages +{ + public partial class PlatformSettingsPage : ContentPage + { + private readonly PlatformSettingsViewModel _viewModel = new(); + + public PlatformSettingsPage() + { + InitializeComponent(); + DataContext = _viewModel; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _viewModel.Subscribe(this.GetPlatformSettings() ?? Application.Current?.PlatformSettings); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _viewModel.Unsubscribe(); + base.OnDetachedFromVisualTree(e); + } + } +} diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml index 06f54dd28a..5e25d927ba 100644 --- a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="using:ControlCatalog.ViewModels" + xmlns:generic="clr-namespace:System.Collections.Generic;assembly=netstandard" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" @@ -12,12 +13,28 @@ - + + + + Used pull direction: + + + + TopToBottom + LeftToRight + RightToLeft + BottomToTop + + + + + + diff --git a/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs new file mode 100644 index 0000000000..62b386b40f --- /dev/null +++ b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace IntegrationTestApp.Pages; + +public partial class KeyboardPage : UserControl +{ + private int _keyDownCount; + + public KeyboardPage() + { + InitializeComponent(); + + // Gestures without a modifier are the interesting case: on macOS the key down used to be + // swallowed by the input context while a text input client was active. + AddKeyBinding(new KeyGesture(Key.Space), "Space"); + AddKeyBinding(new KeyGesture(Key.A), "A"); + AddKeyBinding(new KeyGesture(Key.G, KeyModifiers.Control), "Ctrl+G"); + + // TextBox handles TextInput, and KeyDown for keys like Backspace and the arrows, in a + // class handler that runs before instance handlers. Subscribe with handledEventsToo so + // those keys stay visible here. + KeyDownTextBox.AddHandler(KeyDownEvent, KeyDownTextBox_KeyDown, handledEventsToo: true); + KeyDownTextBox.AddHandler(TextInputEvent, KeyDownTextBox_TextInput, handledEventsToo: true); + } + + private void AddKeyBinding(KeyGesture gesture, string name) + { + GestureScope.KeyBindings.Add(new KeyBinding + { + Gesture = gesture, + Command = new DelegateCommand(() => LastKeyBinding.Text = name) + }); + } + + private void KeyDownTextBox_KeyDown(object? sender, KeyEventArgs e) + { + // While an input method is composing, the key is masked as Key.ImeProcessed but the + // physical key and the key symbol keep their real values. The key symbol is bracketed + // to keep whitespace symbols like the space key's visible and assertable. + LastKeyDown.Text = $"{e.Key}|{e.PhysicalKey}|[{e.KeySymbol}]"; + + // Counts every key down, including the ones flagsChanged raises for modifier keys. + KeyDownCount.Text = (++_keyDownCount).ToString(); + } + + private void KeyDownTextBox_TextInput(object? sender, TextInputEventArgs e) + { + LastTextInput.Text = $"[{e.Text}]"; + } + + private void ResetKeyboard_Click(object? sender, RoutedEventArgs e) + { + _keyDownCount = 0; + KeyDownCount.Text = string.Empty; + LastKeyBinding.Text = string.Empty; + LastKeyDown.Text = string.Empty; + LastTextInput.Text = string.Empty; + GestureTextBox.Text = string.Empty; + KeyDownTextBox.Text = string.Empty; + } +} diff --git a/samples/IntegrationTestApp/Pages/PopupsPage.axaml b/samples/IntegrationTestApp/Pages/PopupsPage.axaml new file mode 100644 index 0000000000..e5c4e7636f --- /dev/null +++ b/samples/IntegrationTestApp/Pages/PopupsPage.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + - - - - - - - - Display Edge To Edge - Use Safe Area - Automatic Paddings - Hide System Bars - - - - - - - + + + + + + + diff --git a/samples/Sandbox/Program.cs b/samples/Sandbox/Program.cs index b676992111..551d6deb23 100644 --- a/samples/Sandbox/Program.cs +++ b/samples/Sandbox/Program.cs @@ -10,7 +10,9 @@ namespace Sandbox public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() +#if DEBUG .WithDeveloperTools() +#endif .LogToTrace(); } } diff --git a/samples/TextTestApp/Program.cs b/samples/TextTestApp/Program.cs index e3931f97d8..3fc3ee2bcd 100644 --- a/samples/TextTestApp/Program.cs +++ b/samples/TextTestApp/Program.cs @@ -23,7 +23,9 @@ namespace TextTestApp { return AppBuilder.Configure() .UsePlatformDetect() +#if DEBUG .WithDeveloperTools() +#endif .LogToTrace(); } } diff --git a/samples/VirtualizationDemo/Program.cs b/samples/VirtualizationDemo/Program.cs index dd289b3b25..2bed2ba006 100644 --- a/samples/VirtualizationDemo/Program.cs +++ b/samples/VirtualizationDemo/Program.cs @@ -7,7 +7,9 @@ class Program public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() +#if DEBUG .WithDeveloperTools() +#endif .LogToTrace(); public static int Main(string[] args) diff --git a/samples/WinUIEmbedSample/App.xaml b/samples/WinUIEmbedSample/App.xaml new file mode 100644 index 0000000000..0bfa0c7b64 --- /dev/null +++ b/samples/WinUIEmbedSample/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/samples/WinUIEmbedSample/App.xaml.cs b/samples/WinUIEmbedSample/App.xaml.cs new file mode 100644 index 0000000000..6524572edd --- /dev/null +++ b/samples/WinUIEmbedSample/App.xaml.cs @@ -0,0 +1,40 @@ +using global::Avalonia; +using global::Avalonia.Controls; +using global::Avalonia.Controls.ApplicationLifetimes; +using global::Avalonia.Skia; +using global::Avalonia.Win32; +using WinUIApplication = Microsoft.UI.Xaml.Application; +using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; +using Window = Microsoft.UI.Xaml.Window; + +namespace WinUIEmbedSample +{ + public partial class App : WinUIApplication + { + private Window? _window; + + internal static SingleViewLifetime Lifetime { get; } = new(); + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + AppBuilder.Configure() + .UseWin32() + .UseSkia() + .UseHarfBuzz() + .SetupWithLifetime(Lifetime); + + _window = new MainWindow(); + _window.Activate(); + } + } + + internal sealed class SingleViewLifetime : ISingleViewApplicationLifetime + { + public Control? MainView { get; set; } + } +} diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml b/samples/WinUIEmbedSample/AvaloniaApp.axaml new file mode 100644 index 0000000000..03c11e5146 --- /dev/null +++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml @@ -0,0 +1,7 @@ + + + + + diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs new file mode 100644 index 0000000000..854a7bd186 --- /dev/null +++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs @@ -0,0 +1,13 @@ +using global::Avalonia; +using global::Avalonia.Markup.Xaml; +using AvApplication = global::Avalonia.Application; + +namespace WinUIEmbedSample; + +public partial class AvaloniaApp : AvApplication +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/samples/WinUIEmbedSample/EmbeddedView.axaml b/samples/WinUIEmbedSample/EmbeddedView.axaml new file mode 100644 index 0000000000..9cfbd4cb1b --- /dev/null +++ b/samples/WinUIEmbedSample/EmbeddedView.axaml @@ -0,0 +1,228 @@ + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + @@ -176,5 +204,16 @@ + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/DatePicker.xaml b/src/Avalonia.Themes.Simple/Controls/DatePicker.xaml index d933afa43e..45d895541d 100644 --- a/src/Avalonia.Themes.Simple/Controls/DatePicker.xaml +++ b/src/Avalonia.Themes.Simple/Controls/DatePicker.xaml @@ -158,6 +158,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/TimePicker.xaml b/src/Avalonia.Themes.Simple/Controls/TimePicker.xaml index 3a5e9ec1b1..53fa121663 100644 --- a/src/Avalonia.Themes.Simple/Controls/TimePicker.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TimePicker.xaml @@ -192,6 +192,10 @@ + + + diff --git a/src/Avalonia.Themes.Simple/Controls/WindowDrawnDecorations.xaml b/src/Avalonia.Themes.Simple/Controls/WindowDrawnDecorations.xaml index 3d76f8b277..acfba2e23e 100644 --- a/src/Avalonia.Themes.Simple/Controls/WindowDrawnDecorations.xaml +++ b/src/Avalonia.Themes.Simple/Controls/WindowDrawnDecorations.xaml @@ -131,7 +131,8 @@ VerticalAlignment="Top" Background="{DynamicResource ThemeAccentBrush}" WindowDecorationProperties.ElementRole="TitleBar"> - - + @@ -215,6 +217,18 @@ + + + +