diff --git a/.github/workflows/build-launcher.yml b/.github/workflows/build-launcher.yml index f02b347d73..0bc61f86e9 100644 --- a/.github/workflows/build-launcher.yml +++ b/.github/workflows/build-launcher.yml @@ -53,6 +53,7 @@ jobs: - name: Build run: | dotnet build build\Stride.Launcher.sln ` + -nr:false ` -v:m -p:WarningLevel=0 ` -p:Configuration=${{ github.event.inputs.build-type || inputs.build-type || 'Debug' }} ` -p:StridePlatforms=Windows ` diff --git a/build/Stride.Launcher.sln b/build/Stride.Launcher.sln index 9c3d48561b..3704abd855 100644 --- a/build/Stride.Launcher.sln +++ b/build/Stride.Launcher.sln @@ -1,9 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28803.352 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 MinimumVisualStudioVersion = 16.0 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Launcher", "..\sources\launcher\Stride.Launcher\Stride.Launcher.csproj", "{0F8BE30E-C41F-4747-B52B-D2D4E13EC6A2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Launcher", "..\sources\launcher\Stride.Launcher\Stride.Launcher.csproj", "{78695DE1-E621-45DF-975D-CFD407081E23}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Launcher.Tests", "..\sources\launcher\Stride.Launcher.Tests\Stride.Launcher.Tests.csproj", "{835D46B3-1E85-4D28-9F25-A1BD7A2C30F7}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core", "..\sources\core\Stride.Core\Stride.Core.csproj", "{BAC8FB10-95ED-4FBB-9925-F6F0E15BD936}" EndProject @@ -21,35 +23,32 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.IO", "..\source EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.MicroThreading", "..\sources\core\Stride.Core.MicroThreading\Stride.Core.MicroThreading.csproj", "{076940AD-70F3-47A8-827C-8E722714F937}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Presentation.Wpf", "..\sources\presentation\Stride.Core.Presentation.Wpf\Stride.Core.Presentation.Wpf.csproj", "{61E90191-22FF-4ADC-AFD0-FAB662589AB4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Translation", "..\sources\core\Stride.Core.Translation\Stride.Core.Translation.csproj", "{8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Packages", "..\sources\assets\Stride.Core.Packages\Stride.Core.Packages.csproj", "{1F5FBA04-C334-41C2-895A-ACC4B786F99E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Presentation.Dialogs", "..\sources\presentation\Stride.Core.Presentation.Dialogs\Stride.Core.Presentation.Dialogs.csproj", "{5EB0493A-076D-4488-AF08-D812FB3FDF7C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Presentation", "..\sources\presentation\Stride.Core.Presentation\Stride.Core.Presentation.csproj", "{0C63EF8B-26F9-4511-9FC5-7431DE9657D6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Translation", "..\sources\core\Stride.Core.Translation\Stride.Core.Translation.csproj", "{8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.CompilerServices", "..\sources\core\Stride.Core.CompilerServices\Stride.Core.CompilerServices.csproj", "{ADE0E241-CBDD-48C3-8F50-98FFE76C03C8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Translation.Presentation", "..\sources\presentation\Stride.Core.Translation.Presentation\Stride.Core.Translation.Presentation.csproj", "{7B286D71-5143-4A08-B9DE-113B310A3F0C}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "10-CoreRuntime", "10-CoreRuntime", "{706260A8-86D4-432D-9FE0-F312863288F5}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Packages", "..\sources\assets\Stride.Core.Packages\Stride.Core.Packages.csproj", "{1F5FBA04-C334-41C2-895A-ACC4B786F99E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "30-CoreDesign", "30-CoreDesign", "{FE721A31-DF09-4E33-B791-BEC6C9E1C6F1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stride.Core.Presentation", "..\sources\presentation\Stride.Core.Presentation\Stride.Core.Presentation.csproj", "{0C63EF8B-26F9-4511-9FC5-7431DE9657D6}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "50-Presentation", "50-Presentation", "{3BC606D7-27B3-41FA-8FB3-9D56AC8B4DD7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stride.Editor.CrashReport", "..\sources\editor\Stride.Editor.CrashReport\Stride.Editor.CrashReport.csproj", "{2880C313-2483-416D-A902-DC2259EDFF64}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stride.Core.Presentation.Avalonia", "..\sources\presentation\Stride.Core.Presentation.Avalonia\Stride.Core.Presentation.Avalonia.csproj", "{3B613E66-671E-4049-8EF5-43CDAA549210}" EndProject Global - GlobalSection(SharedMSBuildProjectFiles) = preSolution - ..\sources\assets\Stride.Core.Assets.Yaml\Stride.Core.Assets.Yaml.projitems*{0f8be30e-c41f-4747-b52b-d2d4e13ec6a2}*SharedItemsImports = 5 - ..\sources\editor\Stride.Core.MostRecentlyUsedFiles\Stride.Core.MostRecentlyUsedFiles.projitems*{0f8be30e-c41f-4747-b52b-d2d4e13ec6a2}*SharedItemsImports = 5 - ..\sources\editor\Stride.PrivacyPolicy\Stride.PrivacyPolicy.projitems*{0f8be30e-c41f-4747-b52b-d2d4e13ec6a2}*SharedItemsImports = 5 - EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0F8BE30E-C41F-4747-B52B-D2D4E13EC6A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0F8BE30E-C41F-4747-B52B-D2D4E13EC6A2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0F8BE30E-C41F-4747-B52B-D2D4E13EC6A2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0F8BE30E-C41F-4747-B52B-D2D4E13EC6A2}.Release|Any CPU.Build.0 = Release|Any CPU + {78695DE1-E621-45DF-975D-CFD407081E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78695DE1-E621-45DF-975D-CFD407081E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78695DE1-E621-45DF-975D-CFD407081E23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78695DE1-E621-45DF-975D-CFD407081E23}.Release|Any CPU.Build.0 = Release|Any CPU {BAC8FB10-95ED-4FBB-9925-F6F0E15BD936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BAC8FB10-95ED-4FBB-9925-F6F0E15BD936}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC8FB10-95ED-4FBB-9925-F6F0E15BD936}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -82,22 +81,10 @@ Global {076940AD-70F3-47A8-827C-8E722714F937}.Debug|Any CPU.Build.0 = Debug|Any CPU {076940AD-70F3-47A8-827C-8E722714F937}.Release|Any CPU.ActiveCfg = Release|Any CPU {076940AD-70F3-47A8-827C-8E722714F937}.Release|Any CPU.Build.0 = Release|Any CPU - {61E90191-22FF-4ADC-AFD0-FAB662589AB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {61E90191-22FF-4ADC-AFD0-FAB662589AB4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {61E90191-22FF-4ADC-AFD0-FAB662589AB4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {61E90191-22FF-4ADC-AFD0-FAB662589AB4}.Release|Any CPU.Build.0 = Release|Any CPU - {5EB0493A-076D-4488-AF08-D812FB3FDF7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5EB0493A-076D-4488-AF08-D812FB3FDF7C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5EB0493A-076D-4488-AF08-D812FB3FDF7C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5EB0493A-076D-4488-AF08-D812FB3FDF7C}.Release|Any CPU.Build.0 = Release|Any CPU {8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}.Debug|Any CPU.Build.0 = Debug|Any CPU {8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}.Release|Any CPU.ActiveCfg = Release|Any CPU {8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69}.Release|Any CPU.Build.0 = Release|Any CPU - {7B286D71-5143-4A08-B9DE-113B310A3F0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B286D71-5143-4A08-B9DE-113B310A3F0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B286D71-5143-4A08-B9DE-113B310A3F0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B286D71-5143-4A08-B9DE-113B310A3F0C}.Release|Any CPU.Build.0 = Release|Any CPU {1F5FBA04-C334-41C2-895A-ACC4B786F99E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1F5FBA04-C334-41C2-895A-ACC4B786F99E}.Debug|Any CPU.Build.0 = Debug|Any CPU {1F5FBA04-C334-41C2-895A-ACC4B786F99E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -106,15 +93,40 @@ Global {0C63EF8B-26F9-4511-9FC5-7431DE9657D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {0C63EF8B-26F9-4511-9FC5-7431DE9657D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {0C63EF8B-26F9-4511-9FC5-7431DE9657D6}.Release|Any CPU.Build.0 = Release|Any CPU - {2880C313-2483-416D-A902-DC2259EDFF64}.Debug|Any CPU.ActiveCfg = Debug|iPhone - {2880C313-2483-416D-A902-DC2259EDFF64}.Debug|Any CPU.Build.0 = Debug|iPhone - {2880C313-2483-416D-A902-DC2259EDFF64}.Release|Any CPU.ActiveCfg = Release|iPhone - {2880C313-2483-416D-A902-DC2259EDFF64}.Release|Any CPU.Build.0 = Release|iPhone + {ADE0E241-CBDD-48C3-8F50-98FFE76C03C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADE0E241-CBDD-48C3-8F50-98FFE76C03C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADE0E241-CBDD-48C3-8F50-98FFE76C03C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADE0E241-CBDD-48C3-8F50-98FFE76C03C8}.Release|Any CPU.Build.0 = Release|Any CPU + {3B613E66-671E-4049-8EF5-43CDAA549210}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B613E66-671E-4049-8EF5-43CDAA549210}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B613E66-671E-4049-8EF5-43CDAA549210}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B613E66-671E-4049-8EF5-43CDAA549210}.Release|Any CPU.Build.0 = Release|Any CPU + {835D46B3-1E85-4D28-9F25-A1BD7A2C30F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {835D46B3-1E85-4D28-9F25-A1BD7A2C30F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {835D46B3-1E85-4D28-9F25-A1BD7A2C30F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {835D46B3-1E85-4D28-9F25-A1BD7A2C30F7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BAC8FB10-95ED-4FBB-9925-F6F0E15BD936} = {706260A8-86D4-432D-9FE0-F312863288F5} + {1F01E12A-50B7-4092-B30A-DEE9638FB753} = {706260A8-86D4-432D-9FE0-F312863288F5} + {53CDAFA0-30DF-404C-AAF0-652CA4047605} = {FE721A31-DF09-4E33-B791-BEC6C9E1C6F1} + {A7AE1C3F-CDC6-42DC-B4C1-5F7150661984} = {FE721A31-DF09-4E33-B791-BEC6C9E1C6F1} + {CA70C887-2A83-45DF-82EB-2DFFA8841B7B} = {706260A8-86D4-432D-9FE0-F312863288F5} + {CEF8B221-56E0-4777-88C1-9E3F9F3D1D3D} = {FE721A31-DF09-4E33-B791-BEC6C9E1C6F1} + {B6687100-3D8C-428C-8288-84607D9D5EDF} = {706260A8-86D4-432D-9FE0-F312863288F5} + {076940AD-70F3-47A8-827C-8E722714F937} = {706260A8-86D4-432D-9FE0-F312863288F5} + {8A23DF78-B3D6-41BD-BA50-19D0FBE4AB69} = {FE721A31-DF09-4E33-B791-BEC6C9E1C6F1} + {0C63EF8B-26F9-4511-9FC5-7431DE9657D6} = {3BC606D7-27B3-41FA-8FB3-9D56AC8B4DD7} + {ADE0E241-CBDD-48C3-8F50-98FFE76C03C8} = {706260A8-86D4-432D-9FE0-F312863288F5} + {3B613E66-671E-4049-8EF5-43CDAA549210} = {3BC606D7-27B3-41FA-8FB3-9D56AC8B4DD7} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {04241BED-1662-4690-BA56-15C99A840CFE} EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + ..\sources\editor\Stride.Core.MostRecentlyUsedFiles\Stride.Core.MostRecentlyUsedFiles.projitems*{78695de1-e621-45df-975d-cfd407081e23}*SharedItemsImports = 5 + EndGlobalSection EndGlobal diff --git a/docs/launcher/README.md b/docs/launcher/README.md new file mode 100644 index 0000000000..74449b2537 --- /dev/null +++ b/docs/launcher/README.md @@ -0,0 +1,99 @@ +# Stride Launcher — Overview + +The Stride Launcher is the entry point end users run after installing Stride. It is an Avalonia MVVM application that manages the locally installed Stride/Xenko versions (download, update, uninstall), exposes recent projects and VSIX extensions for Visual Studio, surfaces release notes, news and documentation, and finally starts the selected version of Game Studio. + +The launcher's sources live in [sources/launcher/](../../sources/launcher/). The application itself is [Stride.Launcher](../../sources/launcher/Stride.Launcher/), built against `net10.0` with RIDs `linux-x64` and `win-x64`. It is distributed as a NuGet package (`Stride.Launcher`) and wrapped by an Advanced Installer setup on Windows. + +## Big picture + +```mermaid +flowchart TD + User["User"] + Setup["StrideSetup.exe
Advanced Installer bundle
sources/launcher/Setup/"] + Prereq["launcher-prerequisites.exe
sources/launcher/Prerequisites/"] + Exe["Stride.Launcher.exe
Avalonia MVVM app
sources/launcher/Stride.Launcher/"] + NuGet["NuGet feed
(packages.stride3d.net, nuget.org)"] + Store["NugetStore
sources/assets/Stride.Core.Packages/"] + GS["Stride.GameStudio
(selected version)"] + + User -- "runs" --> Setup + Setup -- "installs" --> Exe + Setup -- "installs" --> Prereq + Exe -- "uses" --> Store + Store -- "fetches packages" --> NuGet + Exe -- "starts" --> GS + GS -. "optional: /LauncherWindowHandle" .-> Exe +``` + +The launcher has three responsibilities: + +1. **Self-update.** On start, check NuGet for a newer `Stride.Launcher` package and optionally replace the current executable before the UI is shown. See [self-update.md](self-update.md). +2. **Version management.** List available Stride versions, download/install/uninstall them through `NugetStore`, and track a single "active" version. See [versions.md](versions.md). +3. **Launch Game Studio.** Locate the main executable of the active version, start it with the right arguments, and optionally auto-close when Game Studio signals back via `/LauncherWindowHandle`. See [lifecycle.md](lifecycle.md). + +## Projects + +The launcher codebase is small and self-contained under [sources/launcher/](../../sources/launcher/): + +| Directory | Role | +|---|---| +| [Stride.Launcher/](../../sources/launcher/Stride.Launcher/) | Avalonia MVVM application (`Stride.Launcher.exe`) | +| [Prerequisites/](../../sources/launcher/Prerequisites/) | Advanced Installer project producing `launcher-prerequisites.exe` (Windows only) | +| [Setup/](../../sources/launcher/Setup/) | Advanced Installer project producing the user-facing `StrideSetup.exe` bundle (Windows only) | + +The launcher depends on two Stride libraries: + +- [Stride.Core.Packages](../../sources/assets/Stride.Core.Packages/) — the `NugetStore` abstraction used to talk to NuGet feeds. +- [Stride.Core.Presentation.Avalonia](../../sources/presentation/Stride.Core.Presentation.Avalonia/) — the shared Avalonia MVVM framework (dispatcher, dialogs, markdown viewer integration, etc.). The WPF equivalent in the editor is `Stride.Core.Presentation.Wpf`. + +A handful of files from the editor are linked in directly (not as project references) to keep the launcher dependency graph minimal: + +- `EditorPath.cs` — resolves user data paths (`LauncherSettings.conf`, `launcher.lock`, MRU, etc.). +- `PackageSessionHelper.Solution.cs` — parses `.sln` files to discover the Stride version used by a recent project. +- The `Stride.Core.MostRecentlyUsedFiles` shared project — shared MRU list infrastructure. + +See [projects.md](projects.md) for the full layout and each file's role. + +## When you need these systems + +> **Decision tree:** +> +> - Adding a new UI page/tab or a new version-list entry kind? +> → **A new ViewModel + View under Stride.Launcher.** See [viewmodels.md](viewmodels.md) and [views.md](views.md). +> +> - Changing how a Stride version is downloaded, updated, or uninstalled? +> → **`StrideVersionViewModel` and `PackageVersionViewModel`** — they drive `NugetStore`. See [versions.md](versions.md). +> +> - Changing how the launcher updates itself? +> → **`SelfUpdater`** and the self-update window. See [self-update.md](self-update.md). +> +> - Adding a new command-line argument or action? +> → **`LauncherArguments` + `Launcher.ProcessArguments`.** See [lifecycle.md](lifecycle.md#command-line-arguments). +> +> - Persisting a new user preference? +> → **`LauncherSettings`** (launcher-owned) or **`GameStudioSettings`** (shared with Game Studio). See [settings.md](settings.md). +> +> - Adding a new localized string or URL? +> → **`Assets/Localization/Strings.resx` / `Urls.resx`** (+ `.ja-JP` variants). See [localization.md](localization.md). +> +> - Working on the Windows installer or prerequisites bundle? +> → **Advanced Installer projects under `Prerequisites/` and `Setup/`.** See [packaging.md](packaging.md). +> +> - Running/debugging on Linux and something behaves differently? +> → **Platform-specific code paths.** See [cross-platform.md](cross-platform.md). + +## Spoke files + +| File | Covers | +|---|---| +| [projects.md](projects.md) | Directory and file layout, external dependencies, linked files | +| [lifecycle.md](lifecycle.md) | Entry point, single-instance mutex, command-line arguments, error codes, crash reporting | +| [viewmodels.md](viewmodels.md) | `MainViewModel`, version view models, recent projects, news/docs/announcement view models | +| [views.md](views.md) | XAML views, windows, converters, markdown viewer integration | +| [versions.md](versions.md) | Version discovery, install/uninstall flow through `NugetStore`, framework selection, beta filter, dev redirects | +| [self-update.md](self-update.md) | Launcher self-update: NuGet update probe, force-reinstall, file swap, restart | +| [settings.md](settings.md) | `LauncherSettings`, `GameStudioSettings`, config file locations | +| [localization.md](localization.md) | `Strings.resx` / `Urls.resx`, designer classes, adding a new language | +| [packaging.md](packaging.md) | `Stride.Launcher.nuspec`, Advanced Installer projects, `StrideSetup.exe`, versioning | +| [cross-platform.md](cross-platform.md) | Windows-only code paths, Registry usage, Linux/macOS porting status (xplat-launcher) | +| [port-status.md](port-status.md) | Full delta vs the WPF launcher on `master` — including silent regressions — and a phased roadmap to close the gap | diff --git a/docs/launcher/cross-platform.md b/docs/launcher/cross-platform.md new file mode 100644 index 0000000000..db75513032 --- /dev/null +++ b/docs/launcher/cross-platform.md @@ -0,0 +1,72 @@ +# Cross-platform notes + +The launcher is in the middle of a Windows → Avalonia cross-platform port (`xplat-launcher` stream). It targets `net10.0` with RIDs `linux-x64` and `win-x64`; WPF has been fully replaced by Avalonia 12. This file lists the code paths that still behave differently per OS and what the remaining gaps are. + +## Executable shape + +| Platform | Game Studio file | How it is started | +|---|---|---| +| Windows | `Stride.GameStudio.Avalonia.Desktop.exe` (with fallbacks to `Stride.GameStudio.exe`, `Xenko.GameStudio.exe`) | `Process.Start(exe, args)` | +| Linux | `Stride.GameStudio.Avalonia.Desktop.dll` | `Process.Start("dotnet", $"{dll} {args}")` | + +The choice is in `StrideVersionViewModel.GetExecutableNames` and `MainViewModel.StartStudio`. The switch on `Path.GetExtension(mainExecutable)` decides whether to invoke `dotnet` or the binary directly. + +## Windows-only code paths + +Searching for `OperatingSystem.IsWindows()` in the launcher shows the remaining divergences. + +### Prerequisites installer + +`StrideStoreVersionViewModel.RunPrerequisitesInstaller` runs `{InstallPath}/Bin/Prerequisites/install-prerequisites.exe`. That binary is a Windows installer — it is simply skipped on non-Windows since the DirectX / .NET prerequisites it ships don't apply. + +### Visual Studio integration + +`VsixVersionViewModel` relies on `Stride.Core.CodeEditorSupport.VisualStudio.VisualStudioVersions` to find installed VS instances. This uses `vswhere` internally and returns nothing on Linux/macOS, so the VSIX entries are effectively hidden. The code does not branch explicitly — it just finds no targets and the command stays disabled. + +### Installers + +Advanced Installer projects ([Prerequisites/](../../sources/launcher/Prerequisites/), [Setup/](../../sources/launcher/Setup/)) are Windows-only by construction. The MSBuild `PackageInstaller` target silently skips when `AdvancedInstaller.com` is not on `PATH`. On Linux/macOS, distribute the launcher via `dotnet publish -r {rid} --self-contained`. See [packaging.md](packaging.md). + +## Telemetry and privacy policy + +Both `Stride.Metrics` / `MetricsClient` (telemetry) and `PrivacyPolicyHelper` (first-run consent prompt, uninstall-time revoke) have been **intentionally and permanently removed** from the launcher. They will not be ported. No cleanup of legacy privacy-policy state is performed on uninstall — telemetry was removed, so any residual registry keys or settings files from the old WPF launcher are harmless orphans and do not need to be scrubbed. + +## Launcher ↔ GameStudio IPC + +When `AutoCloseLauncher` is on, the launcher passes its own Win32 window handle to Game Studio via the `/LauncherWindowHandle ` argument. Game Studio uses it later to `PostMessage` back at the launcher and ask it to close itself. + +The handle is captured in `MainWindow.OnOpened` only when `OperatingSystem.IsWindows()` is true; on Linux it stays `IntPtr.Zero` and Game Studio's parser ignores it. This is fine on the current branch because Game Studio itself is still Windows-only — a separate effort (`xplat-editor`) is porting it to Avalonia. When that lands, the HWND-based channel will need to be replaced with a cross-platform IPC token (named pipe, socket, or similar), passed via a generalised CLI argument. See [port-status.md](port-status.md) Phase 1 for the rationale. + +## Settings paths + +All config paths go through `EditorPath` (linked file from `Stride.Core.Assets.Editor`). `EditorPath.UserDataPath` resolves to: + +- `%LocalAppData%\Stride\` on Windows +- `$XDG_DATA_HOME/Stride/` (or `~/.local/share/Stride/`) on Linux +- `~/Library/Application Support/Stride/` on macOS + +The launcher writes `LauncherSettings.conf` and the `launcher.lock` single-instance marker under these paths. No OS-specific branching is needed. + +## Icons + +Window icons (`Launcher.ico`) are served through Avalonia's resource system. The `.ico` format is used on every platform; Avalonia picks the best-matching size at runtime. + +## Recent-project "Show in Explorer" + +`RecentProjectViewModel.Explore` reveals the selected recent project in the platform's native file manager: + +- **Windows:** `explorer.exe /select,{path}` (unchanged from master). +- **macOS:** `open -R {path}` — reveals the file in Finder. +- **Linux:** `dbus-send` invocation of `org.freedesktop.FileManager1.ShowItems` on the session bus (implemented by GNOME Nautilus, KDE Dolphin, Cinnamon Nemo, XFCE Thunar, LXDE PCManFM, and others). Falls back to `xdg-open {parent-dir}` when the DBus call fails — e.g. on minimal WMs without an `org.freedesktop.FileManager1` implementer, or headless environments without a session bus. + +All failures are swallowed silently (no dialog, no crash) since they are not actionable from inside the launcher. + +## Testing surface + +When exercising changes, run the launcher on both Windows and Linux. Known gaps that will not reproduce on Linux: + +- Self-update using the `force-reinstall` path (downloads a `StrideSetup.exe` — Windows only). +- First-install VSIX prompt (no VS instances). +- Prerequisites installer on first run. + +Conversely, on Windows the `dotnet` launch path (the `.dll` branch in `StartStudio`) is unreachable unless a Linux-built package is opened. diff --git a/docs/launcher/lifecycle.md b/docs/launcher/lifecycle.md new file mode 100644 index 0000000000..02c2d5798d --- /dev/null +++ b/docs/launcher/lifecycle.md @@ -0,0 +1,113 @@ +# Launcher Lifecycle + +This file covers what happens from the moment the operating system starts `Stride.Launcher.exe` until it exits — process entry, single-instance enforcement, argument parsing, app startup, Game Studio launch, and crash reporting. + +## Entry point + +```mermaid +flowchart TD + OS["OS spawns Stride.Launcher.exe"] + PM["Program.Main
STAThread"] + LM["Launcher.Main
unhandled-exception handler,
ProcessArguments, ProcessAction"] + FL["FileLock.TryLock
launcher.lock in EditorPath.DefaultTempPath"] + RUN["Program.RunNewApp<App>"] + APPMAIN["AppMainAsync:
dispatch LauncherArguments.Actions"] + TR["TryRun → show MainWindow"] + UN["UninstallAsync"] + EXIT["Return LauncherErrorCode"] + + OS --> PM --> LM --> FL + FL -- "got lock" --> RUN --> APPMAIN + APPMAIN --> TR + APPMAIN --> UN + TR --> EXIT + UN --> EXIT + FL -- "already locked" --> EXIT +``` + +- [Program.cs](../../sources/launcher/Stride.Launcher/Program.cs) is the `[STAThread]` entry point. It immediately delegates to `Launcher.Main` and casts the `LauncherErrorCode` enum to `int` for the process exit code. +- [Launcher.cs](../../sources/launcher/Stride.Launcher/Launcher.cs) installs an `AppDomain.UnhandledException` handler, parses arguments, and dispatches to the right action. + +## Single-instance enforcement + +`Launcher.ProcessAction` acquires a `FileLock` over `{EditorPath.DefaultTempPath}/launcher.lock` (see `Stride.Core.IO.FileLock`). The lock is stored in the static `Launcher.Mutex` so the self-updater can release it before spawning a replacement process. If the lock is already held, the launcher pops up a warning dialog through a separate minimal Avalonia app and returns `LauncherErrorCode.ServerAlreadyRunning` (value `1`). + +## Command-line arguments + +[LauncherArguments.cs](../../sources/launcher/Stride.Launcher/LauncherArguments.cs) defines the argument model. Arguments are parsed by `Launcher.ProcessArguments`: + +| Argument | Meaning | +|---|---| +| *(none)* | Default action — show the launcher window and manage versions | +| `/Uninstall` | Clears all other actions and runs `UninstallAsync` | +| `/UpdateTargets` | Appended by `SelfUpdater.RestartApplication` after a self-update (currently not interpreted separately from the default Run) | +| `/LauncherWindowHandle ` | **Outgoing**, not incoming — the launcher passes this to Game Studio when `AutoCloseLauncher` is on, so Game Studio can signal back | + +To add a new action: + +1. Add a value to `LauncherArguments.ActionType`. +2. Parse it in `Launcher.ProcessArguments`. +3. Handle it in the `AppMainAsync` switch inside `Launcher.ProcessAction`. +4. Reserve a new error code range in [LauncherErrorCode.cs](../../sources/launcher/Stride.Launcher/LauncherErrorCode.cs). + +## Error codes + +Exit codes are defined in [LauncherErrorCode.cs](../../sources/launcher/Stride.Launcher/LauncherErrorCode.cs). The convention is: + +- `0` → `Success` +- **Positive** → non-error outcomes (`ServerAlreadyRunning = 1`) +- **Negative** → errors, grouped by action: + - `-1..-100` — RunServer errors + - `-101..-200` — UpdateTargets errors + - `-201..-300` — Uninstall errors + - `-10000` — `UnknownError` + +External installers and wrappers rely on these codes to decide whether to retry, surface a dialog, etc. + +## App startup + +Once the lock is acquired, `Program.RunNewApp` builds the Avalonia app: + +```csharp +AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +``` + +[App.axaml.cs](../../sources/launcher/Stride.Launcher/App.axaml.cs) then: + +1. Attaches Avalonia dev tools in Debug builds. +2. Initializes the global MarkView/Markdig pipeline (alert blocks, footnotes, figures, Mermaid, SVG, TextMate highlighting, link handler that opens URLs via `ShellExecute`). +3. Creates the `ViewModelServiceProvider` with a `DispatcherService` and a `DialogService`. +4. Instantiates `MainViewModel` and wires it as `MainWindow.DataContext`. + +`MinimalApp : App` (same file) is a cut-down app used for secondary windows (crash report, "already running" message, self-update progress). It overrides `OnFrameworkInitializationCompleted` to a no-op so nothing is built beyond what the caller schedules. + +## Game Studio launch + +Clicking **Start** invokes `MainViewModel.StartStudio(string argument)`: + +1. If `AutoCloseLauncher` is on, the launcher prepends `/LauncherWindowHandle {MainViewModel.WindowHandle} ` to the argument string so Game Studio can message it back. +2. `ActiveVersion.LocateMainExecutable()` resolves the path — preferring `{SelectedFramework}` under `tools/` or `lib/`, falling back to legacy paths (`lib/net472/Stride.GameStudio.exe`, `Bin/Windows/Xenko.GameStudio.exe`). +3. On `.dll` targets the launcher runs `dotnet `; otherwise it runs the executable directly. `WorkingDirectory` is set to the directory of the executable so `global.json` resolves correctly. +4. The command is disabled for five seconds to debounce double-clicks, then re-enabled if the version is still `CanStart`. +5. The active version is persisted through `LauncherSettings.ActiveVersion`. + +`MainViewModel.WindowHandle` is a static `IntPtr` set by the view code-behind once the main window is realized — the launcher keeps it on a static so the dialog helpers can reach it without plumbing through another service. + +## Crash reporting + +Two entry points feed the same pipeline: + +- `Launcher.Main`'s `try/catch` (synchronous exceptions during argument parsing / action dispatch). +- `AppDomain.CurrentDomain.UnhandledException` (asynchronous exceptions). + +Both call `HandleException`, which: + +1. Uses `Interlocked.CompareExchange` on `terminating` to make sure we report only once. +2. Forces `en-US` culture so the report is reproducible. +3. Builds a `CrashReportArgs` with the exception, the crash location, and the current thread name. +4. Calls `CrashReport`, which spins up a `MinimalApp`, shows a `CrashReportWindow` bound to a `CrashReportViewModel`, and blocks until it is closed. + +The UI lives under [Crash/](../../sources/launcher/Stride.Launcher/Crash/) — see [views.md](views.md#crash-report). diff --git a/docs/launcher/localization.md b/docs/launcher/localization.md new file mode 100644 index 0000000000..0c90cbbd77 --- /dev/null +++ b/docs/launcher/localization.md @@ -0,0 +1,44 @@ +# Localization + +The launcher is localized via classic .resx resources. Files live in [sources/launcher/Stride.Launcher/Assets/Localization/](../../sources/launcher/Stride.Launcher/Assets/Localization/). + +## File layout + +| File | Role | +|---|---| +| `Strings.resx` | UI strings — default (English) | +| `Strings.ja-JP.resx` | Japanese translations | +| `Strings.Designer.cs` | Auto-generated static accessors for `Strings.*` | +| `Urls.resx` | External URLs — default | +| `Urls.ja-JP.resx` | Japanese URL overrides (e.g. localized documentation) | +| `Urls.Designer.cs` | Auto-generated static accessors for `Urls.*` | + +Both `.resx` files are `EmbeddedResource`; the `.Designer.cs` files are regenerated by the `PublicResXFileCodeGenerator` tool whenever the `.resx` is saved in Visual Studio. Keep the designer files committed so command-line builds (and non-Windows contributors) don't need to rerun the generator. + +`Strings` and `Urls` expose public static getters (e.g. `Strings.AskInstallVersion`, `Urls.RssFeed`). Use them from view models — never hard-code user-facing strings or external URLs. + +## Adding a string + +1. Open `Strings.resx` in Visual Studio (or edit the XML directly). +2. Add the new entry with a descriptive key and English value. +3. Add the **same key** to every sibling file (`Strings.ja-JP.resx`, …). Leave the value in English if no translation is available; Crowdin (see below) will pick it up. +4. Rebuild to regenerate `Strings.Designer.cs` (or edit both the `.resx` and the designer by hand on Linux). +5. Use `Strings.MyNewKey` in code. + +For format strings, include named or positional placeholders (`{0}`, `{1}`) and use `string.Format(Strings.MyFormat, value1, value2)` in code. + +## Adding a URL + +Same procedure, but in `Urls.resx`. Every URL the launcher opens must come from here so it can be localized and pinned per branch — see `Strings.DownloadPage`, `Urls.RssFeed`, `Urls.GettingStarted`, etc. + +URLs that take a version placeholder (documentation, release notes) typically contain `{0}` and are formatted at call time with the active version's major.minor. + +## Adding a language + +1. Create `Strings.{locale}.resx` (e.g. `Strings.fr-FR.resx`) and `Urls.{locale}.resx` next to the existing files. +2. Update [Stride.Launcher.csproj](../../sources/launcher/Stride.Launcher/Stride.Launcher.csproj) to declare them as `EmbeddedResource` (follow the pattern used for `Strings.ja-JP.resx`). +3. Avalonia does not have a built-in language picker in the launcher — the OS UI culture determines the selected locale via standard .NET satellite assembly resolution. + +## Translations workflow + +The repository includes `crowdin.yml` at the root, meaning user-facing `.resx` files can be synced with Crowdin. Check that file for which paths are actually covered before renaming or moving a `.resx`. diff --git a/docs/launcher/packaging.md b/docs/launcher/packaging.md new file mode 100644 index 0000000000..65bab6703a --- /dev/null +++ b/docs/launcher/packaging.md @@ -0,0 +1,90 @@ +# Packaging & Distribution + +The launcher is shipped three different ways, each with its own build artifact. This file describes what is produced, where it comes from, and what to update when versions change. + +## Artifacts + +```mermaid +flowchart LR + src["sources/launcher/Stride.Launcher/"] + nupkg["Stride.Launcher.nupkg
(NuGet, tools/Stride.Launcher.exe)"] + prereq["launcher-prerequisites.exe
(Advanced Installer)"] + setup["StrideSetup.exe
(Advanced Installer bundle)"] + + src --> nupkg + src --> prereq + nupkg --> setup + prereq --> setup +``` + +| Artifact | Source | Consumed by | +|---|---|---| +| `Stride.Launcher.exe` | `dotnet build Stride.Launcher.csproj` | `Stride.Launcher.nuspec` and `StrideSetup.exe` | +| `Stride.Launcher.nupkg` | `Stride.Launcher.nuspec` + `msbuild /t:Pack` | `SelfUpdater` — used to pull in-place updates | +| `launcher-prerequisites.exe` | `Prerequisites/launcher-prerequisites.aip` | `Stride.Launcher.nuspec` (embedded at `tools/Prerequisites/`) | +| `StrideSetup.exe` | `Setup/setup.aip` | End users (first-time install) | + +## Version source of truth + +The single place to bump the launcher version is [Stride.Launcher.nuspec](../../sources/launcher/Stride.Launcher/Stride.Launcher.nuspec)'s `` element. The csproj reads it at build time: + +```xml +<_StrideLauncherNuSpecLines>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)Stride.Launcher.nuspec')) +$([System.Text.RegularExpressions.Regex]::Match($(_StrideLauncherNuSpecLines), `(.*)`).Groups[1].Value) +``` + +So `Stride.Launcher.exe`'s `AssemblyInformationalVersion` — which `SelfUpdater` compares against NuGet — always matches the nuspec. + +The Advanced Installer projects (`.aip`) store their own version independently; remember to bump it when shipping a new setup. + +## Stride.Launcher.nuspec + +```xml + + + + +``` + +Two conventions matter here: + +- Everything that must land next to the exe goes under `tools/` — `SelfUpdater.UpdateLauncherFiles` hard-codes `const string directoryRoot = "tools/"` and ignores anything outside it. +- The prerequisites installer sits in `tools/Prerequisites/` because `StrideStoreVersionViewModel.RunPrerequisitesInstaller` probes there to run it on first install. + +The `` element is special: `SelfUpdater` scans it for a `force-reinstall:` line. See [self-update.md](self-update.md#version-probe). The line that currently ships in the nuspec is used internally; do not remove it. + +## Advanced Installer projects + +### Prerequisites/ + +[Prerequisites/launcher-prerequisites.aip](../../sources/launcher/Prerequisites/launcher-prerequisites.aip) builds a chainer that installs: + +- The .NET Desktop Runtime required by Game Studio. +- DirectX redistributables (matched to the cab files under [Setup/DirectX11/](../../sources/launcher/Setup/DirectX11/)). +- Any other runtime shims Stride expects on a fresh Windows machine. + +The output `launcher-prerequisites.exe` is bundled **inside** the launcher NuGet package at `tools/Prerequisites/`, so a self-update can ship an updated prerequisites bootstrapper too. + +### Setup/ + +[Setup/setup.aip](../../sources/launcher/Setup/setup.aip) is the user-facing installer that downloads `StrideSetup.exe` from `stride3d.net`. It installs: + +- `Stride.Launcher.exe` and its dependencies (unpacked into the installed tools dir). +- A Start menu shortcut with `Launcher.ico`. +- A registry entry so the self-updater can detect the install. + +## Building the installers + +The Advanced Installer projects need `AdvancedInstaller.com` on `PATH`; the automated build is driven by `Stride.build` at the repo root. Locally: + +``` +msbuild sources\launcher\Stride.build /t:Build;PackageInstaller +``` + +`Build` produces `Stride.Launcher.exe`; `PackageInstaller` runs Advanced Installer against the two `.aip` files. + +On Linux and macOS there is no installer story — the launcher is run from a `dotnet publish -r linux-x64 --self-contained` output. The Advanced Installer targets silently skip when `AdvancedInstaller.com` is absent. + +## CI notes + +The launcher CI job is in the main GitHub Actions pipeline. There is a dedicated fix for a parallel-build race — see the commit `ci: fix potential parallel build issue for the launcher job` on this branch. When modifying csproj / nuspec interactions, rerun a full clean build to make sure the regex version probe still picks up the right value. diff --git a/docs/launcher/port-status.md b/docs/launcher/port-status.md new file mode 100644 index 0000000000..3e0cb5ddf6 --- /dev/null +++ b/docs/launcher/port-status.md @@ -0,0 +1,215 @@ +# Port status: Avalonia branch vs `master` (WPF) + +This branch is in the middle of porting the Windows-only WPF launcher (`master`) to a cross-platform Avalonia launcher (`feature/launcher-avalonia-cherrypick`, targeting `net10.0` with `linux-x64` + `win-x64`). [cross-platform.md](cross-platform.md) documents the gaps that are explicitly marked with `TODO` / `FIXME xplat-launcher`. This page is the **complete** delta: features, hooks, visuals, and services that changed between the two branches — including silent regressions that are not flagged anywhere in the code. + +> **Scope.** "Current" = this branch. "Master" = the WPF launcher on `master` at the time of the cherry-pick. Line numbers refer to the current branch unless prefixed with `master:`. + +## What's already ported and working + +The core is in place: + +- Avalonia 12 MVVM app replacing the WPF UI, with `x:DataType` compiled bindings. +- `NugetStore`-backed version discovery, install, uninstall, and update. +- `FileLock`-based cross-platform single-instance mutex (replaces `WindowsMutex`). +- `EditorPath`-based config locations (cross-platform by construction). +- `MarkView.Avalonia` markdown rendering for release notes / news / docs / announcement, with Mermaid + SVG + TextMate code highlighting. +- Self-update flow (NuGet probe → download → file swap → restart). +- Recent projects + MRU integration with Game Studio. +- VSIX discovery via `VisualStudioVersions` (no-op on Linux, by design). +- `ShowBetaVersions` toggle with Avalonia `Interaction.Behaviors` / `DataTriggerBehavior` (ported correctly). +- Recent-project context menu with *Show in Explorer* / *Remove from list* (menu ported; *Show in Explorer* is cross-platform — Windows `explorer.exe /select`, macOS `open -R`, Linux DBus `FileManager1.ShowItems` with `xdg-open` fallback). +- Alternate-versions sub-list (ported as a nested `ItemsControl`, no longer a `Popup`). +- Localization resx / Urls resx. + +## Flagged gaps (already in [cross-platform.md](cross-platform.md)) + +Documented there, recapped for completeness: + +- Prerequisites installer (`install-prerequisites.exe`) and Advanced Installer bundles are Windows-only by construction. + +## Silent regressions (not flagged) + +These are behavioural differences that were not preserved during the port and carry no `TODO` / `FIXME` in the current tree. They compile and appear to work, which is what makes them easy to miss. + +*(Three items previously listed here — `/LauncherWindowHandle` always zero, empty `MainWindow.OnClosing`, and the unpersistent `TabControl.SelectedIndex` — have been resolved. See the 2026-04-22 `feat(launcher): …` commits for the window-lifecycle restoration.)* + +### ~~Recent-project row has an offset hit-test for right-click~~ — Fixed + +~~Observed on Linux during the 2026-04-22 cross-platform-services smoke: right-clicking on a recent-project row does not open the context menu at the row's visible position. The hit-test lands a few pixels lower than the rendered content, so the menu only appears when the user clicks *below* the visible row.~~ + +Fixed (2026-04-27): the `ContextMenu` was attached to the outer `Border` wrapper, not to the `Button` that visually fills the row. On Linux, right-click events on the `Button` did not reliably bubble up to `Border.ContextMenu`. Moved the `ContextMenu` to the `Button` directly and added `VerticalAlignment="Stretch"` so the button fills the full row height — right-click now registers wherever the row is visible. + +### ~~`InstallLatestVersion` fell through when already processing~~ — Fixed (2026-05-02) + +`MainViewModel.InstallLatestVersion` was missing a `return` after showing the "install already in progress" dialog, so `latestVersion.DownloadCommand.Execute()` ran unconditionally even when a download was already running. + +Fixed: added `return` after the dialog + `IsEnabled = false` branch in [MainViewModel.cs](../../sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs). + +### ~~`AnnouncementViewModel` close did not dismiss the overlay~~ — Fixed (2026-05-02) + +`CloseAnnouncement()` set `Validated = true` and optionally saved the "don't show again" flag, but never caused `MainViewModel.Announcement` to be set to `null`. The overlay's `Classes.visible` binding in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml) depends on `Announcement != null`, so clicking **OK** had no visual effect. This was latent only because `DisplayReleaseAnnouncement()` is currently a no-op placeholder. + +Fixed: the `Announcement` setter in [MainViewModel.cs](../../sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs) now subscribes to `AnnouncementViewModel.PropertyChanged` and sets `Announcement = null` when `Validated` becomes `true`. + +### ~~Self-update force-reinstall ran on Linux~~ — Fixed (2026-05-02) + +`SelfUpdater.UpdateLauncherFiles` probed every update package for a `force-reinstall:` line without checking the OS. On Linux this would download a Windows `StrideSetup.exe` and fail at `Process.Start`. The issue was not guarded anywhere despite being flagged as a known gap in [self-update.md](self-update.md) and Phase 4. + +Fixed: the entire force-reinstall block in [SelfUpdater.cs](../../sources/launcher/Stride.Launcher/Services/SelfUpdater.cs) is now wrapped in `if (OperatingSystem.IsWindows())`. On Linux the probe is skipped and the normal in-place file-swap path runs instead. + +### ~~`OpenHyperlinkCommand` lost `.md → .html` rewriting~~ — Fixed (Phase 2) + +~~Master's `Views/Commands.cs`:~~ + +~~Process.Start(new ProcessStartInfo(url.ReplaceLast(".md", ".html")) { UseShellExecute = true });~~ + +Fixed: [App.axaml.cs](../../sources/launcher/Stride.Launcher/App.axaml.cs) `OnLinkClicked` now rewrites URLs ending in `.md` to `.html` before calling `Process.Start`. + +### ~~Announcement overlay lost its slide animation~~ — Fixed (Phase 3) + +~~Master's `Announcement.xaml` wrapped its content in a `Grid` with a `TranslateTransform.X` driven by a `DoubleAnimation` (0.5s, `AccelerationRatio=0.2`, `DecelerationRatio=0.1`) via `Trigger.EnterActions` / `ExitActions` on the `IsEnabled` property. The panel slid in from the right and out to the right.~~ + +~~Current [Announcement.axaml](../../sources/launcher/Stride.Launcher/Views/Announcement.axaml) is a plain `DockPanel` with no transform and no animation — the overlay pops in and out discretely.~~ + +Fixed: the announcement overlay `Border` in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml) now uses a `TransformOperationsTransition` (`translateX(0)` ↔ `translateX(100%)`, 1s, `CubicEaseOut`) driven by `Classes.visible`. + +### ~~Release-notes panel lost its slide animation~~ — Fixed (Phase 3) + +~~Master's `LauncherWindow.xaml` had a dedicated right-side column whose visibility was driven by `ActiveReleaseNotes.IsActive` and whose `TranslateTransform.X` was animated with the same 0.5s easing as the announcement. Current release-notes view appears/disappears without animation.~~ + +Fixed: the release-notes `Grid` in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml) now uses the same `TransformOperationsTransition` approach, driven by `ActiveReleaseNotes.IsActive`. + +### ~~"Show in Explorer" is hard-wired to `explorer.exe`~~ — Fixed (Phase 1) + +~~[RecentProjectViewModel.cs:72](../../sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs#L72):~~ + +```csharp +var startInfo = new ProcessStartInfo("explorer.exe", $"/select,{fullPath.ToOSPath()}") { UseShellExecute = true }; +``` + +~~The menu item is visible on Linux but invocation will fail. Needs a platform switch (`xdg-open {dir}` on Linux, `open -R {path}` on macOS).~~ + +Fixed: `RecentProjectViewModel.Explore` has a full platform switch — Windows `explorer.exe /select`, macOS `open -R`, Linux DBus `org.freedesktop.FileManager1.ShowItems` with `xdg-open {parent-dir}` fallback. See [cross-platform.md](cross-platform.md) § Recent-project "Show in Explorer". + +### `SiliconStudioStrideDir` / `StrideDir` env vars no longer seeded + +Master's `Launcher.cs` prepared these environment variables so legacy Stride ≤ 3.0 Game Studio builds could resolve the install root. Removed on the current branch. Probably acceptable given Stride 2.x / 3.0 is EOL, but worth an explicit decision. + +### ~~`CurrentToolTip` shared status-line behavior~~ — Already present + +~~Master had a `BindCurrentToolTipStringBehavior` wired on every control that updated a shared `MainViewModel.CurrentToolTip` property on hover (a status-bar-style "explain what this control does" line). The binding and the property are both gone from the current branch.~~ + +`BindCurrentToolTipStringBehavior` is implemented in `Stride.Core.Presentation.Avalonia.Behaviors` and is already wired on every relevant control in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml). `MainViewModel.CurrentToolTip` is present and bound in the status bar. Not a regression. + +### ~~`StaysOpenContextMenu` / alternate-versions `Popup`~~ — Already resolved + +~~Master rendered alternate versions in a `Popup` + `ToggleButton` with a custom `StaysOpenContextMenu` class so the popup didn't close on item clicks. Current branch renders them as a nested `ItemsControl` directly in the main list.~~ + +The current branch does use a `ToggleButton` + `Popup` with `IsLightDismissEnabled="True"`. An `EventTriggerBehavior` on each item's `Click` event fires `ChangePropertyAction` to set `Popup.IsOpen = False`, which replicates the `StaysOpenContextMenu` behaviour in Avalonia. Not a regression. + +## Features removed without a replacement + +These are deletions from master that carry no replacement code on this branch. + +### `PrerequisitesValidator` + +- Master's `Program.Main` called `PrerequisitesValidator.Validate(args)` before `Launcher.Main`. That class probed `HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full` for a release key ≥ `461808` (.NET 4.7.2), and if missing, ran `launcher-prerequisites.exe`, waited for exit, and restarted the launcher. +- Current branch has no equivalent. The check was .NET-4.7.2-specific and is obsolete now, but there is no replacement probe for .NET 10.0 runtime availability. On a Windows box without the right runtime, the launcher will fail to start with a platform-level error rather than a friendly prompt. +- On Linux this check doesn't translate — .NET is installed via the package manager — so documenting "Windows self-contained publish handles this" may be all that is needed. + +### `MinimalApp` crash-report / already-running dialog split + +Master had a dedicated `MinimalApp` WPF `Application` instance used for the crash-report dialog and the "another instance is already running" dialog, so those paths did not spin up the full launcher UI. Current branch has a `MinimalApp : App` class at the bottom of [App.axaml.cs](../../sources/launcher/Stride.Launcher/App.axaml.cs) whose `OnFrameworkInitializationCompleted` is intentionally empty. + +This is by design: both dialog paths (`CrashReport` and `DisplayError` in [Launcher.cs](../../sources/launcher/Stride.Launcher/Launcher.cs)) construct and show their window directly inside their own `AppMain` callback, setting `DataContext` and wiring `Closed` themselves. `MinimalApp` therefore does not need to create a `MainViewModel`, initialise the markdown pipeline, or provide any services — skipping all of that is the point. Not a regression. + +## Deliberate changes (for reference, not roadmap items) + +Captured here so reviewers don't try to "revert" them: + +| Area | Master | Current | Reason | +|---|---|---|---| +| Single-instance | `WindowsMutex` + `Process.GetProcessesByName` | `FileLock` under `EditorPath.DefaultTempPath` | Cross-platform | +| Entry point | `[STAThread] Main` → `LauncherInstance().Run()` (WPF, `ShutdownMode.OnExplicitShutdown`) | `Program.Main` → `RunNewApp(AppMain)` (Avalonia classical desktop) | Avalonia lifecycle | +| TFM / RIDs | `net10.0-windows`, `win-x64` | `net10.0`, `linux-x64;win-x64` | Cross-platform | +| Presentation lib | `Stride.Core.Presentation.Wpf` | `Stride.Core.Presentation.Avalonia` | Avalonia port | +| Markdown | Markdig via WPF renderer | `MarkView.Avalonia` | Avalonia port | +| Telemetry | `Stride.Metrics` / `MetricsClient` wrapping the run, `MetricsHelper.NotifyDownload*` around every package op | Removed | **Intentional, permanent** — the xplat launcher does not ship telemetry | +| Privacy policy | `PrivacyPolicyHelper.EnsurePrivacyPolicyStride40()` on startup, `RevokeAllPrivacyPolicy()` on uninstall | Removed | **Intentional, permanent** — the consent flow is no longer needed since telemetry is gone. No uninstall cleanup is performed; any residual registry keys / settings files from the old WPF launcher are harmless orphans | + +## Roadmap + +Ordered by blast radius — each phase is useful on its own. + +### Phase 1 — unblock daily use on both platforms + +These change observable behaviour on both Windows and Linux and should ship first. + +1. ~~**Wire `WindowHandle` on Avalonia `MainWindow`.**~~ **Done** (2026-04-22): HWND captured in `MainWindow.OnOpened` on Windows via `TryGetPlatformHandle()`; stays `IntPtr.Zero` on Linux until xplat-GameStudio lands. See [cross-platform.md](cross-platform.md) § Launcher ↔ GameStudio IPC. +2. ~~**Restore `MainWindow.OnClosing`.**~~ **Done** (2026-04-22): confirmation dialog with `Close anyway` / `Keep launcher open` buttons shown when any version `IsProcessing`; `LauncherSettings.ActiveVersion` persisted on close via `MainViewModel.TryCloseAsync`. +3. ~~**Persist `LauncherSettings.CurrentTab` on tab change.**~~ **Done** (2026-04-22): `MainViewModel.CurrentTab` persisted-on-set, two-way bound from the `TabControl`. +4. ~~**Port `HasDoneTask` / `SaveTaskAsDone` to a file under `EditorPath.UserDataPath`.**~~ **Done** (2026-04-22): one-shot task state moved into `Internal/Launcher/CompletedTasks` inside the existing `LauncherSettings.conf`. No migration — pre-existing `HKCU\SOFTWARE\Stride\` keys on Windows become harmless orphans. +5. ~~**Fix `explorer.exe` hard-coding** in `RecentProjectViewModel.Explore`.~~ **Done** (2026-04-22): platform switch — Windows `explorer.exe /select`, macOS `open -R`, Linux DBus `FileManager1.ShowItems` with `xdg-open` fallback. See [cross-platform.md](cross-platform.md) § Recent-project "Show in Explorer". + +### Phase 2 — feature restoration + +1. ~~**Restore `.md → .html` URL rewriting** in the `OnLinkClicked` handler in [App.axaml.cs](../../sources/launcher/Stride.Launcher/App.axaml.cs) before calling `Process.Start`.~~ **Done** (2026-04-27): `OnLinkClicked` now rewrites `.md` → `.html` before `Process.Start`. +2. **Decide on `.NET 10.0` runtime probe for Windows.** Either embed it as a self-contained publish (no probe needed), or add a small `PrerequisitesValidator` replacement that checks the runtime and surfaces a friendly message. Document the decision in [packaging.md](packaging.md). +3. ~~**Review `MinimalApp` paths.**~~ **Closed (2026-05-02):** `MinimalApp.OnFrameworkInitializationCompleted` is intentionally empty. The crash-report and already-running-instance paths in [Launcher.cs](../../sources/launcher/Stride.Launcher/Launcher.cs) construct and show their window directly inside the `AppMain` callback, so no framework-level initialisation is needed. Not a regression. +4. ~~**Migration cleanup for users upgrading from the WPF launcher.**~~ **Closed (2026-05-02):** decided not to clean up legacy privacy-policy / telemetry state on uninstall. Telemetry was removed entirely, so any residual registry keys or settings files from the old WPF launcher are harmless orphans. The commented-out `RevokeAllPrivacyPolicy` placeholder in `Launcher.cs` has been deleted. + +### Phase 3 — visual parity + +Nice-to-have UX polish. The entries below all map to Avalonia `Transitions` on the relevant `TranslateTransform.X` / `Opacity`, which is how animations are expressed in Avalonia (versus WPF's `Storyboard`/`DoubleAnimation`). + +1. ~~**Announcement slide-in/out**~~ **Done**: `TransformOperationsTransition` on `RenderTransform` (`translateX(0)` ↔ `translateX(100%)`), 1s, `CubicEaseOut`. Implemented via CSS-class binding (`Classes.visible`) on the overlay `Border` in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml). Root `Grid` gained `ClipToBounds="True"` to prevent the panel from showing while off-screen. +2. ~~**Release-notes panel slide**~~ **Done**: Same approach — `TransformOperationsTransition` on the release-notes `Grid` in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml), bound to `ActiveReleaseNotes.IsActive`, 1s, `CubicEaseOut`. The main-content grid's `IsVisible` binding was replaced with `IsEnabled`-only (layout stays; the release-notes panel renders on top while sliding in). +3. ~~**`CurrentToolTip` status-line behavior**~~ **Already done** (pre-existing): `BindCurrentToolTipStringBehavior` is implemented in `Stride.Core.Presentation.Avalonia` and wired on every relevant control in [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml). `MainViewModel.CurrentToolTip` property also present. +4. ~~**Optional: revisit alternate-versions UX.**~~ **Already done** (pre-existing): The current branch uses a `ToggleButton` + `Popup` with `IsLightDismissEnabled="True"` and an `EventTriggerBehavior` that closes the popup on item click — functionally equivalent to the master `StaysOpenContextMenu` approach. + +### Phase 4 — platform expansion + +Not required for parity, but on the horizon: + +1. **Linux packaging.** `dotnet publish -r linux-x64 --self-contained` produces a tree today. Decide on distribution format: tarball, AppImage, Flatpak, or `.deb` / `.rpm`. Update [packaging.md](packaging.md). +2. **macOS support.** No RID yet. Needs `osx-x64` / `osx-arm64` targets, `.app` bundle layout, codesigning / notarization, and window chrome review. +3. **Self-update on Linux.** The `force-reinstall` path is now gated to `OperatingSystem.IsWindows()` (fixed 2026-05-02), so Linux no longer attempts to download `StrideSetup.exe`. The normal in-place file-swap path runs on Linux, but a full `force-reinstall` (breaking-change upgrade) has no Linux equivalent yet. Options: (a) a Linux-only code path that downloads the matching `.tar.gz` / AppImage and replaces the install in place, or (b) "update via your package manager" documented as the intended path. Mentioned in [self-update.md](self-update.md) and [cross-platform.md](cross-platform.md). + +### Phase 5 — test infrastructure + +The launcher has no unit or integration tests today. Bootstrap a test project for the launcher, leveraging Avalonia's headless-platform support so tests can exercise real views (bindings, commands, dialogs, keyboard/mouse input) without a display server. + +1. ~~**Bootstrap `Stride.Launcher.Tests`.**~~ **Done** (2026-04-27): `Stride.Launcher.Tests.csproj` created under `sources/launcher/Stride.Launcher.Tests/`, using xUnit, added to `Stride.Launcher.sln`. Helpers: `InMemoryLauncherSettings`, `FakeDialogService`, `FakeDispatcherService`, `TestViewModelFactory`. To enable internal access, `Stride.Launcher.csproj` uses ``. + + **Prerequisite — `ILauncherSettingsService`** (2026-04-27): introduced to break the direct `LauncherSettings.*` static coupling that would otherwise make view-model tests impossible. `LauncherSettingsService` wraps the real settings; all usages in `MainViewModel`, `AnnouncementViewModel`, `StrideVersionViewModel`, and `MainView.axaml.cs` have been migrated. `MainViewModel` gained an internal test constructor that accepts both `IViewModelServiceProvider` and `ILauncherSettingsService`, skipping NuGet/network/file-system initialisation. + +2. ~~**View-model tests** (partial).~~ **Done** (2026-04-27): 6 tests in [MainViewModelTests.cs](../../sources/launcher/Stride.Launcher.Tests/MainViewModelTests.cs), all passing: + - `HasDoneTask` returns `false` before any task is recorded, `true` after `SaveTaskAsDone`. + - `SaveTaskAsDone` is idempotent (does not double-save). + - `CurrentTab` setter persists the value and calls `Save()` exactly once; no save on no-change. + - `TryCloseAsync` returns `true`, does not invoke the dialog, and saves settings when no version is processing. + + Deferred: `TryCloseAsync` keep-open / close-anyway branches require constructing a `StrideVersionViewModel` with a real `NugetStore` — tracked as follow-up. + +3. **Avalonia headless integration tests.** Exercise the real `MainWindow` + `MainView`: + - `Opened` fires → `MainViewModel.WindowHandle` is non-zero on Windows, zero on Linux. + - `TabControl` selection change propagates to `LauncherSettings.CurrentTab`. + - Simulated close with in-progress download shows the confirmation dialog. +4. **CI wiring.** Run the test project on both `windows-latest` and `ubuntu-latest` in the existing launcher build workflow so platform divergences surface early. + +This phase is not blocked by the others — it can be pulled earlier any time new behaviour needs coverage. Each Phase 1–3 item whose surface is naturally testable should note the tests that will be added here so nothing is forgotten. + +## Beyond parity (proposed enhancements) + +Items that were never in the master WPF launcher but are reasonable next steps once parity is achieved. They are not on any phase above because "do nothing" is a valid answer — only pick them up if they become a real pain point. + +1. **Persist the selected alternate version.** The `Internal/Launcher/ActiveVersion` setting stores only `"Stride ."` (see [StrideVersionViewModel.GetName](../../sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs#L152)). When a user has multiple patch-level builds of the same major.minor installed (e.g., `4.3.0.1` and `4.3.0.2`), the launcher remembers which *major.minor* was active but always selects the default patch on restart. Requires: extending the setting format to capture the full version (or adding a sibling key `ActiveAlternateVersion`), and updating the restore logic in [MainViewModel.RetrieveLocalStrideVersions](../../sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs#L355) to consult it. Surfaced during the 2026-04-22 window-lifecycle smoke. + +## Cross-references + +- [cross-platform.md](cross-platform.md) — the "which OS does what" view; update alongside this page as gaps close. +- [lifecycle.md](lifecycle.md) — entry point, `/LauncherWindowHandle` argument, close flow. +- [viewmodels.md](viewmodels.md) — `MainViewModel` structure and commands. +- [views.md](views.md) — XAML views, converters. +- [self-update.md](self-update.md) — force-reinstall flow. +- [packaging.md](packaging.md) — Advanced Installer projects, NuGet package, target RIDs. diff --git a/docs/launcher/projects.md b/docs/launcher/projects.md new file mode 100644 index 0000000000..069a10a1ba --- /dev/null +++ b/docs/launcher/projects.md @@ -0,0 +1,80 @@ +# Launcher Projects + +## Role + +The launcher is deliberately small and self-contained. There is a single application project plus two Advanced Installer projects used to build the Windows distribution. This file maps every folder and external dependency so you can find what you need without grepping. + +## Directory layout + +``` +sources/launcher/ +├── Stride.Launcher/ +│ ├── Program.cs STAThread entry, Avalonia builder +│ ├── Launcher.cs Core orchestrator: mutex, actions, crash reporting +│ ├── LauncherArguments.cs Argument parsing (/Uninstall) +│ ├── LauncherErrorCode.cs Process exit code enum +│ ├── Constants.cs GameStudio package name constants +│ ├── App.axaml[.cs] Avalonia app, service provider, Markdig pipeline +│ ├── PackageFilterExtensions.cs NuGet package filtering helpers +│ ├── app.manifest Windows DPI/UAC manifest +│ ├── ViewModels/ MVVM layer — see viewmodels.md +│ ├── Views/ XAML views and windows — see views.md +│ ├── Services/ Settings, self-update, uninstall helpers +│ ├── Crash/ Crash reporting view + view model +│ ├── Assets/ +│ │ ├── Images/ Icons, banners, social links +│ │ └── Localization/ Strings.resx, Urls.resx and .ja-JP variants +│ ├── Properties/PublishProfiles/ dotnet publish profiles +│ ├── Stride.Launcher.csproj net10.0, linux-x64 + win-x64 +│ └── Stride.Launcher.nuspec NuGet package definition (single source of truth for version) +├── Prerequisites/ +│ └── launcher-prerequisites.aip Advanced Installer project → launcher-prerequisites.exe +└── Setup/ + ├── setup.aip Advanced Installer project → StrideSetup.exe + ├── Launcher.ico + ├── StrideLogoNoTextWhite.png + └── DirectX11/ DirectX redistributable cabs shipped inside the installer +``` + +## Project references + +[Stride.Launcher.csproj](../../sources/launcher/Stride.Launcher/Stride.Launcher.csproj) references only two Stride projects: + +| Project | Why | +|---|---| +| [Stride.Core.Packages](../../sources/assets/Stride.Core.Packages/) | `NugetStore` — the NuGet facade used for install/uninstall/update and to enumerate local and remote packages. | +| [Stride.Core.Presentation.Avalonia](../../sources/presentation/Stride.Core.Presentation.Avalonia/) | Avalonia-based MVVM framework: `DispatcherService`, `DialogService`, `MessageBox`, `DispatcherViewModel`, common converters. | + +Keeping the reference list this short is intentional — the launcher must start even when no Stride version is installed, so it cannot depend on the editor/runtime assemblies. + +## Linked sources + +A few source files are linked (``) into `Stride.Launcher.csproj` rather than referenced through a project. This is how the launcher reuses editor code without dragging in the entire editor project graph: + +| Linked file | Origin | Used for | +|---|---|---| +| `Editor/EditorPath.cs` | [sources/editor/Stride.Core.Assets.Editor/EditorPath.cs](../../sources/editor/Stride.Core.Assets.Editor/EditorPath.cs) | Resolves `EditorPath.UserDataPath`, `EditorPath.DefaultTempPath`, etc. for settings and the single-instance lock | +| `Packages/PackageSessionHelper.Solution.cs` | [sources/assets/Stride.Core.Assets/PackageSessionHelper.Solution.cs](../../sources/assets/Stride.Core.Assets/PackageSessionHelper.Solution.cs) | Parses `.sln` files to discover the Stride version used by a recent project | +| `Stride.Core.MostRecentlyUsedFiles.projitems` (Shared) | [sources/editor/Stride.Core.MostRecentlyUsedFiles](../../sources/editor/Stride.Core.MostRecentlyUsedFiles/) | Reads the Game Studio MRU list to populate the "Recent projects" tab | + +If you need something from the editor assemblies, prefer linking a single file over adding a project reference. + +## NuGet dependencies + +Pulled in via `Directory.Packages.props`: + +| Package | Purpose | +|---|---| +| `Avalonia.Desktop` | Avalonia runtime (classic desktop lifetime) | +| `Avalonia.Fonts.Inter` | Inter font bundled into the app | +| `Avalonia.Themes.Fluent` | Fluent theme | +| `AvaloniaUI.DiagnosticsSupport` | Dev tools (Debug configuration only) | +| `MarkView.Avalonia.Mermaid` / `.Svg` / `.SyntaxHighlighting` | Rich markdown rendering in release notes / documentation / announcements | + +## Where to put new code + +- **A new version kind** (e.g. nightlies, alternate sources) → new `StrideVersionViewModel` subclass in [Stride.Launcher/ViewModels/](../../sources/launcher/Stride.Launcher/ViewModels/). Follow the existing split between `StrideStoreVersionViewModel` and `StrideDevVersionViewModel`. See [versions.md](versions.md). +- **A new command-line action** → add to `LauncherArguments.ActionType`, wire it through `Launcher.ProcessAction` / `AppMainAsync`. See [lifecycle.md](lifecycle.md#command-line-arguments). +- **A new user preference** → new `SettingsKey` in [LauncherSettings.cs](../../sources/launcher/Stride.Launcher/Services/LauncherSettings.cs). See [settings.md](settings.md). +- **A new localized string** → add to `Strings.resx` and every locale sibling (`Strings.ja-JP.resx`, …). See [localization.md](localization.md). +- **A Windows-only code path** → wrap in `OperatingSystem.IsWindows()` and, for new features, open a FIXME noting the Linux/macOS equivalent. See [cross-platform.md](cross-platform.md). diff --git a/docs/launcher/self-update.md b/docs/launcher/self-update.md new file mode 100644 index 0000000000..10c21be1c8 --- /dev/null +++ b/docs/launcher/self-update.md @@ -0,0 +1,68 @@ +# Launcher Self-Update + +[SelfUpdater.cs](../../sources/launcher/Stride.Launcher/Services/SelfUpdater.cs) is responsible for keeping the launcher itself up to date. It runs early during startup, before the user can interact with the main window, because some updates must complete before the regular UI is allowed to touch `NugetStore`. + +## Flow + +```mermaid +flowchart TD + Start["MainViewModel.FetchOnlineData"] + Self["SelfUpdater.SelfUpdate"] + Updates["store.GetUpdates(Stride.Launcher, currentVersion)"] + Force["Force-reinstall?
(regex match in package description)"] + DL["Download StrideSetup.exe
from the URL in the description"] + RunInstaller["Release launcher.lock,
start installer,
Environment.Exit(0)"] + Req["Any 'req' intermediate version?
Take the earliest — otherwise take the latest."] + Need["Newer than current?"] + Show["Show SelfUpdateWindow
(modal, locked)"] + Install["store.InstallPackage(launcher package)"] + Swap["Move current files to .old,
copy new files from tools/"] + Restart["RestartApplication
(append /UpdateTargets)"] + Skip["Return, continue normal startup"] + + Start --> Self --> Updates --> Force + Force -- "yes, older than minimum" --> DL --> RunInstaller + Force -- "no" --> Req --> Need + Need -- "no" --> Skip + Need -- "yes" --> Show --> Install --> Swap --> Restart +``` + +## Version probe + +`SelfUpdater.Version` is read once from the assembly's `AssemblyInformationalVersionAttribute`. The package id is taken from `AssemblyProductAttribute.Product` — keep these MSBuild properties in sync with `Stride.Launcher.nuspec`. + +`store.GetUpdates` returns candidate packages newer than the current version. The updater walks them twice: + +1. **Force-reinstall probe.** If any candidate's `Description` contains a line matching `force-reinstall:\s*(\S+)\s*(\S+)` and the current version is below the declared minimum, the launcher downloads the setup from the URL in the match and hands off to the installer. This is how the launcher reboots across breaking changes that a plain file swap cannot handle. The nuspec description in [Stride.Launcher.nuspec](../../sources/launcher/Stride.Launcher/Stride.Launcher.nuspec) contains a sample line; do not remove it — it is used internally. +2. **Required intermediate probe.** A candidate with `SpecialVersion == "req"` is taken as a mandatory intermediate step. Otherwise the latest candidate is taken directly. This lets you ship a one-off "req" release that every client must pass through. + +## File swap + +When an in-place update is possible: + +1. A `SelfUpdateWindow` is shown modally on top of the main window; `LockWindow()` disables its close button for the duration. +2. `NugetStore.InstallPackage` downloads the package. +3. `package.GetFiles()` is filtered to entries under `tools/` (must match the layout in [Stride.Launcher.nuspec](../../sources/launcher/Stride.Launcher/Stride.Launcher.nuspec) — ``). +4. Each target file (the launcher exe, its `.config`, and everything in `tools/`) is first moved to `.old`, then replaced. If any copy throws, every `.old` is rolled back. +5. `store.PurgeCache()` clears NuGet's stream cache so subsequent launches don't reopen the old package. +6. `RestartApplication` adds `/UpdateTargets` to `args`, releases `Launcher.Mutex`, starts a new process with `UseShellExecute = true`, and calls `Environment.Exit(0)`. + +## Mutex release + +Both the force-reinstall and the file-swap paths must release `Launcher.Mutex` before spawning the replacement process — otherwise the newly-started launcher would hit `ServerAlreadyRunning` and bail out. Every `Process.Start` call in `SelfUpdater` is immediately preceded by `Launcher.Mutex?.Dispose()`. + +## Failure modes + +- **HTTP failure in force-reinstall.** Shows a `MessageBox` with `Strings.NewVersionDownloadError`; the launcher keeps running against the old version. +- **Partial file swap.** `.old` files are renamed back and the exception is re-thrown. `SelfUpdateWindow.ForceClose()` drops the modal, then `MainViewModel.FetchOnlineData` shows the full error (with `LogMessages`). +- **No network at all.** `FetchOnlineData`'s catch swallows `HttpRequestException` so the launcher still starts in offline mode. Any other failure calls `Environment.Exit(1)` — the product decision is that running against a known-broken launcher is worse than not running. + +## Testing + +Self-update is hard to test end-to-end. For local iteration: + +1. Build a `Stride.Launcher` package with a bumped version via `PackageLauncher-Debug.bat`. +2. Point a local NuGet feed at the output. +3. Add the feed in `nuget.config` and relaunch the installed launcher. + +For force-reinstall paths, add a matching `force-reinstall:` line to the description in a throwaway nuspec and host the referenced URL on a local HTTP server. diff --git a/docs/launcher/settings.md b/docs/launcher/settings.md new file mode 100644 index 0000000000..b13cbb6635 --- /dev/null +++ b/docs/launcher/settings.md @@ -0,0 +1,55 @@ +# Launcher Settings + +The launcher persists two kinds of data: + +- **Launcher-owned preferences** — `LauncherSettings.conf`, read/written only by the launcher. +- **Game Studio shared data** — the MRU list and the crash-report email. These live in Game Studio's own settings files; the launcher reads them to populate the "Recent projects" tab and to carry the email over if a crash happens. + +Both go through the [Stride.Core.Settings](../../sources/core/Stride.Core.Design/Settings/) infrastructure (`SettingsContainer` + typed `SettingsKey`), same as the editor. + +## LauncherSettings + +[LauncherSettings.cs](../../sources/launcher/Stride.Launcher/Services/LauncherSettings.cs) owns the launcher's own state. File path: `{EditorPath.UserDataPath}/LauncherSettings.conf`. + +| Key | Default | Written when | +|---|---|---| +| `Internal/Launcher/CloseLauncherAutomatically` | `false` | User toggles the "Close launcher after starting Game Studio" checkbox | +| `Internal/Launcher/ActiveVersion` | `""` | `MainViewModel.StartStudio` — the name of the version used to start Game Studio | +| `Internal/Launcher/PreferredFramework` | `"net10.0"` | User picks a framework in the framework combo — set by `MainView.FrameworkChanged` | +| `Internal/Launcher/CurrentTabSessions` | `0` | User changes the active tab | +| `Internal/Launcher/DeveloperVersions` | `[]` | Dev versions added manually by advanced users (no UI yet) — consumed at startup to add `StrideDevVersionViewModel` entries | + +`LauncherSettings.Save()` writes every field back. The class is static because the launcher has a single profile and no concept of user accounts. + +### Adding a new preference + +1. Declare a `SettingsKey` with a unique path (prefix `Internal/Launcher/`). +2. Add a public static property that mirrors it and is read at static-init time. +3. Mention it in the `Save()` method so it is persisted when the user acts on it. +4. If the value must survive mid-session changes, call `LauncherSettings.Save()` from the setter that owns it. + +## GameStudioSettings + +[GameStudioSettings.cs](../../sources/launcher/Stride.Launcher/Services/GameStudioSettings.cs) reads Game Studio's own settings files: + +- `EditorPath.InternalConfigPath` — `Internal/MostRecentlyUsedSessions` (the MRU dictionary). Also includes a legacy deserializer for the 1.3-era plain list format. +- `EditorPath.EditorConfigPath` — `Interface/StoreCrashEmail` (the user's email opt-in for crash reports). + +The MRU list is wrapped in `MostRecentlyUsedFileCollection` from the shared [Stride.Core.MostRecentlyUsedFiles](../../sources/editor/Stride.Core.MostRecentlyUsedFiles/) project. + +**File watching.** `SettingsProfile.MonitorFileModification = true` is set on the internal settings profile. When Game Studio rewrites the file from another process, `FileModified` fires, `UpdateMostRecentlyUsed` reloads the dictionary, and `RecentProjectsUpdated` is raised. `MainViewModel` dispatches back onto the UI thread and rebuilds `RecentProjects`. + +**Mutation.** Only `RemoveMostRecentlyUsed` is exposed — the user can remove an entry from the launcher, but new entries only appear when Game Studio records them. + +## EditorPath + +Path resolution goes through [Stride.Core.Assets.Editor.EditorPath](../../sources/editor/Stride.Core.Assets.Editor/EditorPath.cs), linked directly into the launcher project. On Windows this resolves to `%LocalAppData%\Stride\`; on Linux/macOS it follows XDG conventions. + +## First-install tasks + +`MainViewModel.HasDoneTask(name)` / `SaveTaskAsDone(name)` uses `HKCU\SOFTWARE\Stride\` on Windows to remember whether a one-off task has already run. This is used for: + +- `PrerequisitesRun` — the prerequisites installer is only auto-launched on the very first run. +- Announcement dismissal — `AnnouncementViewModel` stores "don't show again" per announcement name. + +On Linux/macOS, `HasDoneTask` currently returns `true` unconditionally, meaning announcements are never shown and the prerequisites installer never runs — this matches the target platforms (no Windows DirectX redist needed). A `FIXME xplat-editor` comment in the code marks this for a future file-based implementation. See [cross-platform.md](cross-platform.md). diff --git a/docs/launcher/versions.md b/docs/launcher/versions.md new file mode 100644 index 0000000000..860f976db9 --- /dev/null +++ b/docs/launcher/versions.md @@ -0,0 +1,93 @@ +# Stride Versions + +Managing Stride versions is the launcher's core responsibility. This file describes how versions are discovered, how install/update/uninstall flows are wired, and where to hook new behavior. + +## Version kinds + +```mermaid +flowchart TD + SVM["StrideVersionViewModel
(abstract)"] + SSV["StrideStoreVersionViewModel
Major.Minor from NuGet feed"] + SSA["StrideStoreAlternateVersionViewModel
Alternate package ID under the same Major.Minor
(e.g. legacy Xenko)"] + SDV["StrideDevVersionViewModel
Local build or DevRedirect package"] + + SVM --> SSV + SVM --> SDV + SSV --> SSA +``` + +Each entry in `MainViewModel.StrideVersions` corresponds to one `Major.Minor` pair. A `StrideStoreVersionViewModel` holds both the latest `NugetLocalPackage` installed and the latest `NugetServerPackage` from the feed — that's how the UI knows whether a version is installed, outdated, or download-only. + +## Version discovery + +`MainViewModel.FetchOnlineData` orchestrates three phases: + +1. **Local.** `RetrieveLocalStrideVersions` calls `NugetStore.GetPackagesInstalled(MainPackageIds)` and filters with `FilterStrideMainPackages()` ([PackageFilterExtensions.cs](../../sources/launcher/Stride.Launcher/PackageFilterExtensions.cs)). Packages are grouped by `{Major}.{Minor}`. +2. **Server.** `RetrieveServerStrideVersions` calls `NugetStore.FindSourcePackages(MainPackageIds, …)`. If the call returns nothing, `IsOffline` flips on and the UI shows an error with the accumulated log. +3. **Dev.** Dev-redirect packages (detected via `NugetStore.IsDevRedirectPackage`) are added as `StrideDevVersionViewModel` with their real on-disk path from `NugetStore.GetRealPath`. Additionally, every entry in `LauncherSettings.DeveloperVersions` is added up front. + +The same `Major.Minor` key is looked up with `SortedObservableCollection.BinarySearch(Tuple.Create(major, minor))` to merge local and server state into a single view model. + +## NuGet store + +[Stride.Core.Packages.NugetStore](../../sources/assets/Stride.Core.Packages/NugetStore.cs) is a thin facade over NuGet's v3 APIs. The launcher does not call NuGet directly — every operation goes through `NugetStore`: + +| Method | Used by | +|---|---| +| `GetPackagesInstalled(ids)` | `RetrieveLocalStrideVersions` | +| `FindSourcePackages(ids, ct)` | `RetrieveServerStrideVersions`, `VsixVersionViewModel.UpdateFromStore` | +| `InstallPackage(id, version, frameworks, progress)` | `PackageVersionViewModel` download flow, `SelfUpdater` | +| `UninstallPackage(package, progress)` | `PackageVersionViewModel` delete flow, `MainViewModel.RemoveUnusedPackages`, `Launcher.UninstallAsync` | +| `GetUpdates(identity, …)` | `SelfUpdater` | +| `PurgeCache()` | `SelfUpdater` after a file swap | + +Every call is serialized through `MainViewModel.RunLockTask`, which takes `objectLock` and runs on a background thread. + +Progress is reported via `IPackagesLogger` — `MainViewModel` implements it and stores every log line in-memory so the crash report and the "offline" dialog can attach it. + +## Install / update flow + +1. User clicks **Install** or **Update** on a `StrideVersionViewModel`. +2. `PackageVersionViewModel.Download(true)` runs: it sets `IsProcessing`, calls `NugetStore.InstallPackage`, and updates progress via `OnDownloadProgress`. +3. On completion, `UpdateStatus` recomputes `CanBeDownloaded` / `CanDelete` and the UI re-binds. +4. `MainViewModel.RetrieveLocalStrideVersions` is re-run to refresh the version list and to clean up any newly-unused transitive packages via `RemoveUnusedPackages` (walks `Dependencies` starting from the Stride/Xenko main packages and uninstalls anything no longer referenced). +5. `UpdateFrameworks()` re-scans `tools/` and `lib/` for TFM subfolders containing a Game Studio executable (`Stride.GameStudio.Avalonia.Desktop.exe` on Windows, `.dll` on Linux). `SelectedFramework` is restored from `LauncherSettings.PreferredFramework` if present, otherwise the closest match (same `Framework` identifier) is used. + +## Uninstall flow + +Two entry points: + +- **Per-version**, from the UI: `PackageVersionViewModel.Delete(removeFromUi: true, confirmPrompt: true)` prompts the user, calls `NugetStore.UninstallPackage`, and updates status. +- **Full uninstall**, from `Stride.Launcher.exe /Uninstall`: [Launcher.cs](../../sources/launcher/Stride.Launcher/Launcher.cs)'s `UninstallAsync`: + 1. Calls `UninstallHelper.CloseProcessesInPathAsync` to kill any running Stride/Game Studio process started from the launcher directory. The user is prompted to confirm via `MessageBox`. + 2. Iterates `store.MainPackageIds` and uninstalls every matching local package. + 3. Cleans `.lock` and `.old` files left over from previous self-updates. + 4. Cancels the app's `CancellationTokenSource` so the main loop exits. + +`UninstallHelper` also subscribes to `NugetStore.NugetPackageUninstalling` to close lingering processes before each package is removed — this is why it lives as a disposable member on `MainViewModel` (`uninstallHelper`). + +## Beta filter + +`StrideVersionViewModel.IsBetaVersion(major, minor)` returns `true` for `major < 3`. Beta versions are visible only when `MainViewModel.ShowBetaVersions` is on or when the version is already installed. The UI toggle re-raises `UpdateStatus` for every version. + +## Framework selection + +`StrideVersionViewModel.Frameworks` is an `ObservableList` populated by scanning the package install path. The launcher looks for: + +``` +{InstallPath}/tools/{framework}/Stride.GameStudio.Avalonia.Desktop.{exe|dll} +{InstallPath}/lib/{framework}/Stride.GameStudio.Avalonia.Desktop.{exe|dll} +``` + +On Windows, `Stride.GameStudio.exe` and legacy `Xenko.GameStudio.exe` are also considered. See `StrideVersionViewModel.GetExecutableNames` and `LocateMainExecutable`. + +## VSIX + +`VsixVersionViewModel` treats the VSIX as just another NuGet package but overrides the install step to invoke `VSIXInstaller.exe` against the detected `VisualStudioVersions.AvailableInstances` matching the supported range (`VS2019` for major 16, `VS2022AndNext` for major ≥ 17). Two instances exist on `MainViewModel`: `VsixPackage2019` and `VsixPackage2022`. + +## Adding a new version kind + +1. Create a subclass of `StrideVersionViewModel` (or `StrideStoreVersionViewModel` for "looks like a store version but from a different source"). +2. Override `UpdateStatus` to describe when the entry is downloadable vs installed. +3. Populate it from `MainViewModel` — either from a settings source (like `LauncherSettings.DeveloperVersions`) or from a new NuGet query. +4. If the UI needs to distinguish it, add a DataTemplate in `MainView.axaml` keyed on the concrete type. diff --git a/docs/launcher/viewmodels.md b/docs/launcher/viewmodels.md new file mode 100644 index 0000000000..151278ff40 --- /dev/null +++ b/docs/launcher/viewmodels.md @@ -0,0 +1,110 @@ +# Launcher ViewModels + +The launcher's MVVM layer lives under [sources/launcher/Stride.Launcher/ViewModels/](../../sources/launcher/Stride.Launcher/ViewModels/). Every view model derives from `DispatcherViewModel` from `Stride.Core.Presentation`, so property change notifications marshal back to the UI thread automatically. + +## Hierarchy + +```mermaid +flowchart TD + MVVM["MainViewModel
(root)"] + SVM["StrideVersionViewModel (abstract)"] + SSV["StrideStoreVersionViewModel"] + SDV["StrideDevVersionViewModel"] + SSA["StrideStoreAlternateVersionViewModel"] + PVM["PackageVersionViewModel (abstract)"] + VVM["VsixVersionViewModel"] + RPV["RecentProjectViewModel"] + NPV["NewsPageViewModel"] + DPV["DocumentationPageViewModel"] + RNV["ReleaseNotesViewModel"] + AVM["AnnouncementViewModel"] + + PVM --> SVM + PVM --> VVM + SVM --> SSV + SVM --> SDV + SSV --> SSA + + MVVM -- owns --> SVM + MVVM -- owns --> VVM + MVVM -- owns --> RPV + MVVM -- owns --> NPV + MVVM -- owns --> DPV + MVVM -- owns --> RNV + MVVM -- owns --> AVM +``` + +## MainViewModel + +[MainViewModel.cs](../../sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs) is the root. It is created once in `App.OnFrameworkInitializationCompleted` and owns everything else. + +Key responsibilities: + +- **Initialize the NuGet store.** `Launcher.InitializeNugetStore()` constructs a `NugetStore` rooted at the launcher's directory. `MainViewModel` registers itself as `store.Logger` via `IPackagesLogger` so every NuGet operation is captured in the `LogMessages` buffer. +- **Populate `StrideVersions`.** A `SortedObservableCollection` (newest first) combining: + 1. Dev versions from `LauncherSettings.DeveloperVersions`. + 2. Locally installed packages (`RetrieveLocalStrideVersions`). + 3. Packages returned by the NuGet feed (`RetrieveServerStrideVersions`). +- **Track the active version.** `ActiveVersion` persists to `LauncherSettings.ActiveVersion`. When it changes, `StartStudioCommand.IsEnabled` is recomputed from `ActiveVersion.CanStart`. +- **Fetch online content.** `FetchOnlineData` runs the self-updater, then in parallel fetches news pages, release notes, and documentation TOC. +- **First-install experience.** `CheckForFirstInstall` runs the prerequisites installer for already-installed versions, then — on a truly first install — asks the user to install the latest Stride and the appropriate VSIX for their installed Visual Studio. +- **Recent projects.** Subscribes to `GameStudioSettings.RecentProjectsUpdated` and rebuilds `RecentProjects` on the dispatcher. + +Commands exposed to the UI: + +| Command | Action | +|---|---| +| `InstallLatestVersionCommand` | Download the newest `StrideVersionViewModel` via its own `DownloadCommand` | +| `StartStudioCommand` | Launch Game Studio for `ActiveVersion` (see [lifecycle.md](lifecycle.md#game-studio-launch)) | +| `OpenUrlCommand` | `ShellExecute` a URL (used for social links, docs, news) | +| `ReconnectCommand` | Clear `IsOffline` and re-run `FetchOnlineData` | +| `CheckDeprecatedSourcesCommand` | Add the legacy `packages.stride3d.net` feed to NuGet.config if missing, then restart | + +`RunLockTask` serializes every NuGet operation under `objectLock` so two async tasks never hit `NugetStore` concurrently. + +## PackageVersionViewModel + +[PackageVersionViewModel.cs](../../sources/launcher/Stride.Launcher/ViewModels/PackageVersionViewModel.cs) is the abstract base for anything the launcher downloads from NuGet. + +It holds: + +- `LocalPackage` / `ServerPackage` — `NugetLocalPackage` / `NugetServerPackage` from `Stride.Core.Packages`. +- `CanBeDownloaded`, `CanDelete`, `IsProcessing`, `CurrentProgress`, `CurrentProgressAction`, `CurrentProcessStatus` — UI-facing flags. +- `DownloadCommand` and `DeleteCommand` — the primary user actions. + +Subclasses must implement `Name` and `FullName`, and override `UpdateStatus` to set `CanBeDownloaded` / `CanDelete` based on the local/server pair. The download/delete logic is already in the base class. + +## Version view models + +- [**StrideVersionViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs) — abstract base. Adds `Major`/`Minor`, `IsBeta`, `Frameworks` (discovered by scanning `tools/`/`lib/`), `SelectedFramework`, and `LocateMainExecutable()`. `IsBeta` is hard-coded to `major < 3`. +- [**StrideStoreVersionViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/StrideStoreVersionViewModel.cs) — an official release. Holds `ReleaseNotes` and `DocumentationPages`, manages the prerequisites installer (`Bin\Prerequisites\install-prerequisites.exe`), and exposes the VSIX packages. +- [**StrideStoreAlternateVersionViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/StrideStoreAlternateVersionViewModel.cs) — a sibling variant (e.g. legacy Xenko ID) that resolves under the same `StrideStoreVersionViewModel`. +- [**StrideDevVersionViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/StrideDevVersionViewModel.cs) — a local build registered via `LauncherSettings.DeveloperVersions` or a `DevRedirect` package. Never downloadable. Marked as "compatible with every project" in the recent projects list. +- [**VsixVersionViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/VsixVersionViewModel.cs) — installs/uninstalls the Stride Visual Studio extension in the detected VS instances via `Stride.Core.CodeEditorSupport.VisualStudio.VisualStudioVersions`. + +See [versions.md](versions.md) for the full install/uninstall flow. + +## Recent projects + +[RecentProjectViewModel.cs](../../sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs) wraps a single MRU entry: + +- `DiscoverStrideVersion()` runs async on a background thread and parses the `.sln` file through `PackageSessionHelper` to detect which Stride version the project targets. +- `CompatibleVersions` lists the locally-installed versions that can open the project (dev versions are always considered compatible; store versions must be ≥ the project's declared version). +- Commands: `OpenCommand` (open with active version), `OpenWithCommand` (open with a chosen version), `ExploreCommand` (show in file explorer), `RemoveCommand` (remove from MRU via `GameStudioSettings.RemoveMostRecentlyUsed`). + +## Content view models + +- [**NewsPageViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/NewsPageViewModel.cs) — fetches the RSS feed at `Urls.RssFeed`, parses ``, `<description>`, `<pubDate>`, `<link>`. `FetchNewsPages(IViewModelServiceProvider, int)` returns up to *n* entries. +- [**DocumentationPageViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/DocumentationPageViewModel.cs) — fetches the "Getting Started" HTML index and extracts its links. Tied to a specific Stride version so the right doc branch is shown. +- [**ReleaseNotesViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/ReleaseNotesViewModel.cs) — lazy-loads markdown release notes; has `IsLoading`/`IsLoaded`/`IsUnavailable` flags and a `ToggleCommand` to expand/collapse. +- [**AnnouncementViewModel**](../../sources/launcher/Stride.Launcher/ViewModels/AnnouncementViewModel.cs) — loads a markdown announcement from an embedded resource. Uses `MainViewModel.HasDoneTask` / `SaveTaskAsDone` so "don't show again" is persisted per announcement. Currently `MainViewModel.DisplayReleaseAnnouncement` is an empty placeholder — wire announcements here. + +## Converter + +[FrameworkConverter.cs](../../sources/launcher/Stride.Launcher/ViewModels/FrameworkConverter.cs) is a one-way value converter that renders a NuGet framework folder name (`net10.0-windows`) as a human-readable label (`.NET 10.0 (Windows)`). It lives in the ViewModels folder rather than Views because it is consumed by bindings alongside `SelectedFramework`. + +## Threading notes + +- Mutations that touch `strideVersions` go through `Dispatcher.Invoke` / `Dispatcher.InvokeAsync` because the UI observes the collection. +- All NuGet calls go through `MainViewModel.RunLockTask` to serialize access. +- `strideVersions` is a `SortedObservableCollection`; never `Add` into an index — it sorts itself. diff --git a/docs/launcher/views.md b/docs/launcher/views.md new file mode 100644 index 0000000000..ad71b14f71 --- /dev/null +++ b/docs/launcher/views.md @@ -0,0 +1,51 @@ +# Launcher Views + +All XAML lives under [sources/launcher/Stride.Launcher/Views/](../../sources/launcher/Stride.Launcher/Views/) plus the specialized [Crash/](../../sources/launcher/Stride.Launcher/Crash/) folder. Compiled bindings are on by default (`<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>`) so every `DataContext` is typed. + +## Main window + +- [MainWindow.axaml](../../sources/launcher/Stride.Launcher/Views/MainWindow.axaml) is the `Window` shell. Its code-behind wires the window handle back to `MainViewModel.WindowHandle` so it can be passed to Game Studio via `/LauncherWindowHandle`. +- [MainView.axaml](../../sources/launcher/Stride.Launcher/Views/MainView.axaml) is the content `UserControl`. It hosts the version list on the left, the tabs (Versions / Recent projects / News / Documentation) on the right, the announcement overlay, and the bottom bar with the Start Studio / Install buttons. `MainView.axaml.cs` has a `FrameworkChanged` handler that saves the user's framework choice to `LauncherSettings.PreferredFramework` immediately. + +Splitting the `Window` from a `UserControl` lets Avalonia's designer render `MainView` in a `SingleViewApplicationLifetime` (see `App.OnFrameworkInitializationCompleted`'s second branch). + +## Announcement overlay + +[Announcement.axaml](../../sources/launcher/Stride.Launcher/Views/Announcement.axaml) is bound to `MainViewModel.Announcement`. When the view model is non-null, the overlay renders its markdown through the shared `MarkdownViewer`. The user can dismiss it or tick "don't show again", which calls back through `MainViewModel.SaveTaskAsDone`. + +## Self-update window + +[SelfUpdateWindow.axaml](../../sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml) is a small modal progress dialog. `SelfUpdater` creates it via `dispatcher.InvokeAsync`, calls `LockWindow()` to prevent the user from closing it during critical file operations, and calls `ForceClose()` if the update fails. See [self-update.md](self-update.md). + +## Crash report + +Under [Crash/](../../sources/launcher/Stride.Launcher/Crash/): + +- [CrashReportWindow.axaml](../../sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml) — the dialog. Shows the exception summary, a toggleable details pane, and buttons to copy the report or open a new GitHub issue. +- [CrashReportViewModel.cs](../../sources/launcher/Stride.Launcher/Crash/CrashReportViewModel.cs) — the view model. Formats the `CrashReportData` for display, uses the clipboard delegate passed in by `Launcher.CrashReport` (set to `window.Clipboard.SetTextAsync`), and exposes `CopyReportCommand`, `OpenIssueCommand`, `ViewReportCommand`, `CloseCommand`. +- [CrashReportData.cs](../../sources/launcher/Stride.Launcher/Crash/CrashReportData.cs) and [CrashReportArgs.cs](../../sources/launcher/Stride.Launcher/Crash/CrashReportArgs.cs) — the serializable shapes passed between the exception handler and the view. + +The crash report runs under a brand-new `MinimalApp`, because the main `App` is typically in the middle of shutting down. + +## Converters + +Two converters live next to the views instead of in the ViewModels folder because they are UI-only: + +- [ProgressToIndeterminatedConverter.cs](../../sources/launcher/Stride.Launcher/Views/ProgressToIndeterminatedConverter.cs) — returns `true` when progress is unknown so the `ProgressBar` flips to indeterminate mode. +- `FrameworkConverter` (in ViewModels/ but used in XAML) — see [viewmodels.md](viewmodels.md#converter). + +## Markdown rendering + +Every `MarkdownViewer` in the launcher shares a single pipeline configured once in `App.InitializeMarkdownViewer`: + +- `Markdig` extensions: abbreviations, alert blocks, figures, footnotes, media links, plus the generic `UseSupportedExtensions`. +- `MarkView` extensions: TextMate syntax highlighting, SVG, Mermaid. +- A global `LinkClickedEvent` class handler routes every `<a>` click through `Process.Start(..., UseShellExecute = true)` so URLs open in the user's browser rather than inside the app. + +This is how release notes, announcements, and error dialogs get consistent rendering. + +## Where to put new XAML + +- **New tab in the main window** → extend `MainView.axaml` with a new `TabItem` and bind its content to a new property on `MainViewModel`. +- **New modal dialog** → create an Avalonia `Window` with its own view model and show it through `IDialogService` (so tests can stub it). +- **New crash surface** → do not add more `MinimalApp` spawn sites; reuse `CrashReportWindow` and extend `CrashReportData`/`CrashReportViewModel`. diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index 5e717b5e60..59fd8abe89 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -90,16 +90,23 @@ </ItemGroup> <!-- Avalonia dependencies --> <PropertyGroup> - <AvaloniaVersion>11.0.6</AvaloniaVersion> + <AvaloniaVersion>12.0.1</AvaloniaVersion> + <AvaloniaBehaviorVersion>12.0.0</AvaloniaBehaviorVersion> + <AvaloniaMarkViewVersion>12.0.2</AvaloniaMarkViewVersion> </PropertyGroup> <ItemGroup> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersion)" /> - <PackageVersion Include="Avalonia.Controls.DataGrid" Version="$(AvaloniaVersion)" /> + <PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.0.0" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersion)" /> - <PackageVersion Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersion)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersion)" /> - <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.2" /> + <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" /> + <PackageVersion Include="Xaml.Behaviors.Avalonia" Version="$(AvaloniaBehaviorVersion)" /> + <PackageVersion Include="Xaml.Behaviors.Interactions" Version="$(AvaloniaBehaviorVersion)" /> + <PackageVersion Include="MarkView.Avalonia" Version="$(AvaloniaMarkViewVersion)" /> + <PackageVersion Include="MarkView.Avalonia.Mermaid" Version="$(AvaloniaMarkViewVersion)" /> + <PackageVersion Include="MarkView.Avalonia.Svg" Version="$(AvaloniaMarkViewVersion)" /> + <PackageVersion Include="MarkView.Avalonia.SyntaxHighlighting" Version="$(AvaloniaMarkViewVersion)" /> </ItemGroup> <!-- Windows/WPF dependencies --> <ItemGroup> diff --git a/sources/core/Stride.Core.Yaml.Tests/Stride.Core.Yaml.Tests.csproj b/sources/core/Stride.Core.Yaml.Tests/Stride.Core.Yaml.Tests.csproj index ff9bc44faa..1a5056d4fc 100644 --- a/sources/core/Stride.Core.Yaml.Tests/Stride.Core.Yaml.Tests.csproj +++ b/sources/core/Stride.Core.Yaml.Tests/Stride.Core.Yaml.Tests.csproj @@ -12,6 +12,7 @@ <ItemGroup> <ProjectReference Include="..\..\tests\xunit.runner.stride\xunit.runner.stride.csproj" /> <ProjectReference Include="..\Stride.Core.Yaml\Stride.Core.Yaml.csproj" /> + <PackageReference Include="System.Drawing.Common" Condition="'$(TargetFramework)' == '$(StrideFramework)'" /> </ItemGroup> <Import Project="$(StrideRoot)sources/sdk/Stride.Build.Sdk.Tests/Sdk/Sdk.targets" /> </Project> diff --git a/sources/launcher/README.md b/sources/launcher/README.md index c24ea20218..80650c0066 100644 --- a/sources/launcher/README.md +++ b/sources/launcher/README.md @@ -1,9 +1,66 @@ Stride Launcher ============== -Source for Stride launcher and installer. +Source for the Stride Launcher and its Windows installer. + +The launcher is the entry point that end users run after installing Stride. It manages the installed Stride/Xenko versions (download, update, uninstall), exposes recent projects, VSIX extensions for Visual Studio, release notes, news, and documentation, and finally starts the selected version of Game Studio. + +It is an [Avalonia](https://avaloniaui.net/) MVVM application, targeting `net10.0` with runtime identifiers `linux-x64` and `win-x64`. It is distributed as a NuGet package (`Stride.Launcher`) and wrapped by an [Advanced Installer](https://www.advancedinstaller.com/) setup on Windows. + +# Project layout + +``` +sources/launcher/ +├── Stride.Launcher/ Avalonia MVVM application +├── Prerequisites/ Advanced Installer project bundling .NET / DirectX prerequisites +└── Setup/ Advanced Installer project producing the final StrideSetup.exe +``` + +See [docs/launcher/](../../docs/launcher/) for contributor-oriented documentation on the launcher's internals. # Build instructions -Please check out sources in `<StrideDir>\sources\launcher`. -You can then use `msbuild Stride.build /t:Build;PackageInstaller`. +## From the command line (Windows) + +Check out sources in `<StrideDir>\sources\launcher`. You can then run: + +``` +msbuild Stride.build /t:Build;PackageInstaller +``` + +This builds `Stride.Launcher.exe`, the prerequisites installer, and the final setup bundle. Building the installer targets requires Advanced Installer to be installed on the machine. + +## From the .NET CLI (cross-platform) + +To build only the launcher application (no installer): + +``` +dotnet build sources/launcher/Stride.Launcher/Stride.Launcher.csproj +``` + +To publish a self-contained Windows build: + +``` +dotnet publish sources/launcher/Stride.Launcher/Stride.Launcher.csproj -c Release -r win-x64 +``` + +To publish a self-contained Linux build: + +``` +dotnet publish sources/launcher/Stride.Launcher/Stride.Launcher.csproj -c Release -r linux-x64 +``` + +## From Visual Studio / Rider + +Open `build/Stride.sln` (or `sources/launcher/Stride.Launcher/Stride.Launcher.csproj`) and build the `Stride.Launcher` project. Set it as the startup project to launch it under the debugger. + +A convenience launcher script, [PackageLauncher-Debug.bat](Stride.Launcher/PackageLauncher-Debug.bat), packages a Debug build as a NuGet package for local testing. + +# Versioning + +The launcher version is the single source of truth in [Stride.Launcher.nuspec](Stride.Launcher/Stride.Launcher.nuspec). The csproj reads the `<version>` element at build time, so bump the version there to release a new launcher. + +# Further reading + +- [Launcher contributor documentation](../../docs/launcher/README.md) — architecture, view models, services, packaging, cross-platform notes. +- [Stride documentation](https://doc.stride3d.net/) — end-user documentation. diff --git a/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDialogService.cs b/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDialogService.cs new file mode 100644 index 0000000000..f0e807ff9c --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDialogService.cs @@ -0,0 +1,45 @@ +using Stride.Core.IO; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.Windows; + +namespace Stride.Launcher.Tests.Helpers; + +internal sealed class FakeDialogService : IDialogService +{ + private int nextMultiButtonResult; + + public bool WasCalled { get; private set; } + + public void SetNextResult(int result) => nextMultiButtonResult = result; + + public Task<int> MessageBoxAsync(string message, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None) + { + WasCalled = true; + return Task.FromResult(nextMultiButtonResult); + } + + public Task<MessageBoxResult> MessageBoxAsync(string message, MessageBoxButton buttons = MessageBoxButton.OK, MessageBoxImage image = MessageBoxImage.None) + => Task.FromResult(MessageBoxResult.OK); + + public Task<CheckedMessageBoxResult> CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, MessageBoxButton buttons = MessageBoxButton.OK, MessageBoxImage image = MessageBoxImage.None) + => throw new NotImplementedException(); + + public Task<CheckedMessageBoxResult> CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None) + => throw new NotImplementedException(); + + public Task<UFile?> OpenFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null) + => throw new NotImplementedException(); + + public Task<IReadOnlyList<UFile>> OpenMultipleFilesPickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null) + => throw new NotImplementedException(); + + public Task<UDirectory?> OpenFolderPickerAsync(UDirectory? initialPath = null) + => throw new NotImplementedException(); + + public Task<UFile?> SaveFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null, string? defaultExtension = null, string? defaultFileName = null) + => throw new NotImplementedException(); + + public void Exit(int exitCode = 0) => throw new NotImplementedException(); + + public bool HasMainWindow => false; +} diff --git a/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDispatcherService.cs b/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDispatcherService.cs new file mode 100644 index 0000000000..ec0e71853c --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/Helpers/FakeDispatcherService.cs @@ -0,0 +1,33 @@ +using Stride.Core.Presentation.Services; + +namespace Stride.Launcher.Tests.Helpers; + +internal sealed class FakeDispatcherService : IDispatcherService +{ + public void Invoke(Action callback) => callback(); + + public TResult Invoke<TResult>(Func<TResult> callback) => callback(); + + public Task InvokeAsync(Action callback, CancellationToken token = default) + { + callback(); + return Task.CompletedTask; + } + + public Task LowPriorityInvokeAsync(Action callback, CancellationToken token = default) + { + callback(); + return Task.CompletedTask; + } + + public Task<TResult> InvokeAsync<TResult>(Func<TResult> callback, CancellationToken token = default) + => Task.FromResult(callback()); + + public Task InvokeTask(Func<Task> task, CancellationToken token = default) => task(); + + public Task<TResult> InvokeTask<TResult>(Func<Task<TResult>> task, CancellationToken token = default) => task(); + + public bool CheckAccess() => true; + + public void EnsureAccess(bool inDispatcherThread = true) { } +} diff --git a/sources/launcher/Stride.Launcher.Tests/Helpers/InMemoryLauncherSettings.cs b/sources/launcher/Stride.Launcher.Tests/Helpers/InMemoryLauncherSettings.cs new file mode 100644 index 0000000000..b204e31a01 --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/Helpers/InMemoryLauncherSettings.cs @@ -0,0 +1,30 @@ +using Stride.Core.IO; +using Stride.Launcher.Services; + +namespace Stride.Launcher.Tests.Helpers; + +internal sealed class InMemoryLauncherSettings : ILauncherSettingsService +{ + private readonly List<string> completedTasks = []; + + public bool CloseLauncherAutomatically { get; set; } + public string ActiveVersion { get; set; } = ""; + public string PreferredFramework { get; set; } = "net10.0"; + public int CurrentTab { get; set; } + public IReadOnlyCollection<UDirectory> DeveloperVersions { get; init; } = []; + + public int SaveCallCount { get; private set; } + + public bool IsTaskCompleted(string taskName) => completedTasks.Contains(taskName); + + public void MarkTaskCompleted(string taskName) + { + if (!completedTasks.Contains(taskName)) + { + completedTasks.Add(taskName); + SaveCallCount++; + } + } + + public void Save() => SaveCallCount++; +} diff --git a/sources/launcher/Stride.Launcher.Tests/Helpers/TestViewModelFactory.cs b/sources/launcher/Stride.Launcher.Tests/Helpers/TestViewModelFactory.cs new file mode 100644 index 0000000000..39d00d5d95 --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/Helpers/TestViewModelFactory.cs @@ -0,0 +1,18 @@ +using Stride.Core.Presentation.ViewModels; +using Stride.Launcher.ViewModels; + +namespace Stride.Launcher.Tests.Helpers; + +internal static class TestViewModelFactory +{ + internal static (MainViewModel Vm, InMemoryLauncherSettings Settings, FakeDialogService Dialog) + CreateMainViewModel() + { + var settings = new InMemoryLauncherSettings(); + var dialog = new FakeDialogService(); + var dispatcher = new FakeDispatcherService(); + var serviceProvider = new ViewModelServiceProvider([dispatcher, dialog, settings]); + var vm = new MainViewModel(serviceProvider, settings); + return (vm, settings, dialog); + } +} diff --git a/sources/launcher/Stride.Launcher.Tests/MainViewModelTests.cs b/sources/launcher/Stride.Launcher.Tests/MainViewModelTests.cs new file mode 100644 index 0000000000..78dd60712e --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/MainViewModelTests.cs @@ -0,0 +1,68 @@ +using Stride.Launcher.Tests.Helpers; +using Xunit; + +namespace Stride.Launcher.Tests; + +public sealed class MainViewModelTests +{ + [Fact] + public void HasDoneTask_ReturnsFalse_WhenTaskNotRecorded() + { + var (vm, _, _) = TestViewModelFactory.CreateMainViewModel(); + Assert.False(vm.HasDoneTask("SomeTask")); + } + + [Fact] + public void HasDoneTask_ReturnsTrue_AfterSaveTaskAsDone() + { + var (vm, _, _) = TestViewModelFactory.CreateMainViewModel(); + vm.SaveTaskAsDone("SomeTask"); + Assert.True(vm.HasDoneTask("SomeTask")); + } + + [Fact] + public void SaveTaskAsDone_IsIdempotent() + { + var (vm, settings, _) = TestViewModelFactory.CreateMainViewModel(); + vm.SaveTaskAsDone("SomeTask"); + vm.SaveTaskAsDone("SomeTask"); + Assert.Equal(1, settings.SaveCallCount); + } + + [Fact] + public void CurrentTab_Setter_PersistsValueAndSaves() + { + var (vm, settings, _) = TestViewModelFactory.CreateMainViewModel(); + var savesBefore = settings.SaveCallCount; + + vm.CurrentTab = 1; + + Assert.Equal(1, settings.CurrentTab); + Assert.Equal(savesBefore + 1, settings.SaveCallCount); + } + + [Fact] + public void CurrentTab_Setter_DoesNotSave_WhenValueUnchanged() + { + var (vm, settings, _) = TestViewModelFactory.CreateMainViewModel(); + vm.CurrentTab = 0; // default is already 0; SetValue returns false → no save + var savesBefore = settings.SaveCallCount; + + vm.CurrentTab = 0; + + Assert.Equal(savesBefore, settings.SaveCallCount); + } + + [Fact] + public async Task TryCloseAsync_ReturnsTrue_AndPersists_WhenNoVersionIsProcessing() + { + var (vm, settings, dialog) = TestViewModelFactory.CreateMainViewModel(); + var savesBefore = settings.SaveCallCount; + + var result = await vm.TryCloseAsync(); + + Assert.True(result); + Assert.False(dialog.WasCalled); + Assert.Equal(savesBefore + 1, settings.SaveCallCount); + } +} diff --git a/sources/launcher/Stride.Launcher.Tests/Stride.Launcher.Tests.csproj b/sources/launcher/Stride.Launcher.Tests/Stride.Launcher.Tests.csproj new file mode 100644 index 0000000000..50763a9eb9 --- /dev/null +++ b/sources/launcher/Stride.Launcher.Tests/Stride.Launcher.Tests.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Stride.Launcher\Stride.Launcher.csproj" /> + </ItemGroup> +</Project> diff --git a/sources/launcher/Stride.Launcher/App.axaml b/sources/launcher/Stride.Launcher/App.axaml new file mode 100644 index 0000000000..30e5363016 --- /dev/null +++ b/sources/launcher/Stride.Launcher/App.axaml @@ -0,0 +1,62 @@ +<Application xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + x:Class="Stride.Launcher.App" + RequestedThemeVariant="Default"> + <Application.Resources> + <ResourceDictionary> + <Image x:Key="ImageFacebook" + Height="24" Width="24" + Source="/Assets/Images/facebook.png" /> + <Image x:Key="ImageOpenCollective" + Height="24" Width="24" + Source="/Assets/Images/opencollective.png" /> + <Image x:Key="ImageReddit" + Height="24" Width="24" + Source="/Assets/Images/reddit.png" /> + <Image x:Key="ImageTwitter" + Height="24" Width="24" + Source="/Assets/Images/xtwitter.png" /> + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <SolidColorBrush x:Key="BackgroundEmphasis" Color="#900520" /> + <SolidColorBrush x:Key="BackgroundTileAlpha" Color="#C0434343" /> + <SolidColorBrush x:Key="BorderBrushTile" Color="Transparent" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Dark"> + <SolidColorBrush x:Key="BackgroundEmphasis" Color="{DynamicResource SystemAccentColorDark1}" /> + <SolidColorBrush x:Key="BackgroundTileAlpha" Color="#C0434343" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <SolidColorBrush x:Key="BackgroundEmphasis" Color="{DynamicResource SystemAccentColorLight1}" /> + <SolidColorBrush x:Key="BackgroundTileAlpha" Color="#C0BCBCBC" /> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + <Thickness x:Key="BorderThicknessTile">12</Thickness> + <Thickness x:Key="BorderThicknessTileList">0,0,0,20</Thickness> + </ResourceDictionary> + </Application.Resources> + + <Application.Styles> + <FluentTheme> + <FluentTheme.Palettes> + <ColorPaletteResources x:Key="Dark"/> + <ColorPaletteResources x:Key="Light"/> + </FluentTheme.Palettes> + </FluentTheme> + <StyleInclude Source="avares://MarkView.Avalonia/Themes/MarkdownTheme.axaml" /> + + <Style Selector="Button.MediaButton"> + <Setter Property="Margin" Value="2" /> + <Setter Property="Padding" Value="0" /> + </Style> + <Style Selector="Button.MediaButton:pointerover"> + <Setter Property="Opacity" Value="0.6" /> + </Style> + <Style Selector="Button.TransparentButton"> + <Setter Property="Background" Value="Transparent" /> + </Style> + <Style Selector="ToggleButton.TransparentButton"> + <Setter Property="Background" Value="Transparent" /> + </Style> + </Application.Styles> +</Application> diff --git a/sources/launcher/Stride.Launcher/App.axaml.cs b/sources/launcher/Stride.Launcher/App.axaml.cs new file mode 100644 index 0000000000..29ec297919 --- /dev/null +++ b/sources/launcher/Stride.Launcher/App.axaml.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using MarkView.Avalonia; +using MarkView.Avalonia.Rendering; +using Stride.Core.Presentation.Avalonia.Services; +using Stride.Core.Presentation.ViewModels; +using Stride.Launcher.Services; +using Stride.Launcher.ViewModels; +using Stride.Launcher.Views; + +namespace Stride.Launcher; + +public partial class App : Application +{ + internal readonly CancellationTokenSource cts = new(); + + internal MainWindow? MainWindow { get; private set; } + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + +#if DEBUG + this.AttachDeveloperTools(); +#endif + } + + public override void OnFrameworkInitializationCompleted() + { + InitializeMarkdownViewer(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = MainWindow = new() + { + DataContext = InitializeMainViewModel() + }; + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + // don't remove; also used by visual designer. + singleViewPlatform.MainView = new MainView + { + DataContext = InitializeMainViewModel() + }; + } + } + + private static MainViewModel InitializeMainViewModel() + { + return new(InitializeServiceProvider()); + } + + private static void InitializeMarkdownViewer() + { + // Global pipeline — applies to every MarkdownViewer in the app + MarkdownViewerDefaults.Pipeline = new Markdig.MarkdownPipelineBuilder() + .UseSupportedExtensions() + .UseAbbreviations() + .UseAlertBlocks() + .UseFigures() + .UseFootnotes() + .UseMediaLinks() + .Build(); + + // Global extensions — applies to every MarkdownViewer in the app + MarkdownViewerDefaults.Extensions.AddTextMateHighlighting(); + MarkdownViewerDefaults.Extensions.AddSvg(); + MarkdownViewerDefaults.Extensions.AddMermaid(); + + // Global link handler — handles external links for every MarkdownViewer in the app + MarkdownViewer.LinkClickedEvent.AddClassHandler<MarkdownViewer>(OnLinkClicked); + + static void OnLinkClicked(MarkdownViewer sender, LinkClickedEventArgs e) + { + try + { + var url = e.Url.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? e.Url[..^3] + ".html" + : e.Url; + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Ignore failures to open browser + } + } + } + + private static ViewModelServiceProvider InitializeServiceProvider() + { + var dispatcherService = DispatcherService.Create(); + var services = new object[] + { + dispatcherService, + new DialogService(dispatcherService) { ApplicationName = Launcher.ApplicationName }, + new LauncherSettingsService(), + }; + return new ViewModelServiceProvider(services); + } +} + +// This app is used for the crash report or for the notification when an instance is already running +internal sealed class MinimalApp : App +{ + public override void OnFrameworkInitializationCompleted() { } +} diff --git a/sources/launcher/Stride.Launcher/App.xaml b/sources/launcher/Stride.Launcher/App.xaml deleted file mode 100644 index dc853949d5..0000000000 --- a/sources/launcher/Stride.Launcher/App.xaml +++ /dev/null @@ -1,20 +0,0 @@ -<Application x:Class="Stride.LauncherApp.App" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - <Application.Resources> - <ResourceDictionary> - <ResourceDictionary.MergedDictionaries> - <ResourceDictionary Source="/Stride.Core.Presentation.Wpf;component/Themes/ThemeSelector.xaml"/> - </ResourceDictionary.MergedDictionaries> - - <DrawingImage x:Key="VectorEditorIcon"> - <DrawingImage.Drawing> - <DrawingGroup> - <GeometryDrawing Brush="#000000" Geometry="F1 M18,20z M0,0z M9.3457031,0.17382812L1.5566406,4.7675781 9,9.140625 16.791016,4.5488281 9.3457031,0.17382812z M0.86132812,5.9941406L0.86132812,9.5488281 8.3046875,13.923828 8.3046875,10.369141 0.86132812,5.9941406z M13.417969,8.1816406L9.6953125,10.369141 9.6953125,13.923828 16.443359,9.9589844 13.417969,8.1816406z M17.140625,11.185547L14.810547,12.554688 17.140625,13.923828 17.140625,11.185547z M4.4140625,13.248047L1.0117188,15.171875 8.9785156,19.826172 16.705078,15.279297 13.417969,13.373047 9,15.96875 4.4140625,13.248047z" /> - </DrawingGroup> - </DrawingImage.Drawing> - </DrawingImage> - - </ResourceDictionary> - </Application.Resources> -</Application> diff --git a/sources/launcher/Stride.Launcher/App.xaml.cs b/sources/launcher/Stride.Launcher/App.xaml.cs deleted file mode 100644 index 0ed6af7ada..0000000000 --- a/sources/launcher/Stride.Launcher/App.xaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -namespace Stride.LauncherApp -{ - /// <summary> - /// Interaction logic for App.xaml - /// </summary> - public partial class App - { - } -} diff --git a/sources/launcher/Stride.Launcher/Assets/CrashReportImage.png b/sources/launcher/Stride.Launcher/Assets/CrashReportImage.png new file mode 100644 index 0000000000..b1bf3de6f6 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Assets/CrashReportImage.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36917dc9595f57e66f7d9692cd77fc19e8cc0f7eb31d76fe117d877ad72b2d31 +size 3019 diff --git a/sources/launcher/Stride.Launcher/Resources/EditorIcon.png b/sources/launcher/Stride.Launcher/Assets/Images/EditorIcon.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/EditorIcon.png rename to sources/launcher/Stride.Launcher/Assets/Images/EditorIcon.png diff --git a/sources/launcher/Stride.Launcher/Resources/chat-16.png b/sources/launcher/Stride.Launcher/Assets/Images/chat.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/chat-16.png rename to sources/launcher/Stride.Launcher/Assets/Images/chat.png diff --git a/sources/launcher/Stride.Launcher/Resources/delete-26-dark.png b/sources/launcher/Stride.Launcher/Assets/Images/delete.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/delete-26-dark.png rename to sources/launcher/Stride.Launcher/Assets/Images/delete.png diff --git a/sources/launcher/Stride.Launcher/Resources/discord.png b/sources/launcher/Stride.Launcher/Assets/Images/discord.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/discord.png rename to sources/launcher/Stride.Launcher/Assets/Images/discord.png diff --git a/sources/launcher/Stride.Launcher/Resources/download-26-dark.png b/sources/launcher/Stride.Launcher/Assets/Images/download.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/download-26-dark.png rename to sources/launcher/Stride.Launcher/Assets/Images/download.png diff --git a/sources/launcher/Stride.Launcher/Resources/facebook_24.png b/sources/launcher/Stride.Launcher/Assets/Images/facebook.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/facebook_24.png rename to sources/launcher/Stride.Launcher/Assets/Images/facebook.png diff --git a/sources/launcher/Stride.Launcher/Resources/getting-started.png b/sources/launcher/Stride.Launcher/Assets/Images/getting-started.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/getting-started.png rename to sources/launcher/Stride.Launcher/Assets/Images/getting-started.png diff --git a/sources/launcher/Stride.Launcher/Resources/github.png b/sources/launcher/Stride.Launcher/Assets/Images/github.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/github.png rename to sources/launcher/Stride.Launcher/Assets/Images/github.png diff --git a/sources/launcher/Stride.Launcher/Resources/issues.png b/sources/launcher/Stride.Launcher/Assets/Images/issues.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/issues.png rename to sources/launcher/Stride.Launcher/Assets/Images/issues.png diff --git a/sources/launcher/Stride.Launcher/Resources/list-26.png b/sources/launcher/Stride.Launcher/Assets/Images/list.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/list-26.png rename to sources/launcher/Stride.Launcher/Assets/Images/list.png diff --git a/sources/launcher/Stride.Launcher/Resources/news.png b/sources/launcher/Stride.Launcher/Assets/Images/news.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/news.png rename to sources/launcher/Stride.Launcher/Assets/Images/news.png diff --git a/sources/launcher/Stride.Launcher/Resources/note-26-dark.png b/sources/launcher/Stride.Launcher/Assets/Images/note.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/note-26-dark.png rename to sources/launcher/Stride.Launcher/Assets/Images/note.png diff --git a/sources/launcher/Stride.Launcher/Resources/opencollective_24.png b/sources/launcher/Stride.Launcher/Assets/Images/opencollective.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/opencollective_24.png rename to sources/launcher/Stride.Launcher/Assets/Images/opencollective.png diff --git a/sources/launcher/Stride.Launcher/Resources/recent-projects.png b/sources/launcher/Stride.Launcher/Assets/Images/recent-projects.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/recent-projects.png rename to sources/launcher/Stride.Launcher/Assets/Images/recent-projects.png diff --git a/sources/launcher/Stride.Launcher/Resources/reddit_24.png b/sources/launcher/Stride.Launcher/Assets/Images/reddit.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/reddit_24.png rename to sources/launcher/Stride.Launcher/Assets/Images/reddit.png diff --git a/sources/launcher/Stride.Launcher/Resources/roadmap.png b/sources/launcher/Stride.Launcher/Assets/Images/roadmap.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/roadmap.png rename to sources/launcher/Stride.Launcher/Assets/Images/roadmap.png diff --git a/sources/launcher/Stride.Launcher/Assets/Images/robot.png b/sources/launcher/Stride.Launcher/Assets/Images/robot.png new file mode 100644 index 0000000000..9e25c59043 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Assets/Images/robot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:876b4471b32cb4e9fc354e9d84519b06ca03cc6cdbc56f3780374e511512efdf +size 362967 diff --git a/sources/launcher/Stride.Launcher/Resources/showcase.png b/sources/launcher/Stride.Launcher/Assets/Images/showcase.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/showcase.png rename to sources/launcher/Stride.Launcher/Assets/Images/showcase.png diff --git a/sources/launcher/Stride.Launcher/Resources/survey.png b/sources/launcher/Stride.Launcher/Assets/Images/survey.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/survey.png rename to sources/launcher/Stride.Launcher/Assets/Images/survey.png diff --git a/sources/launcher/Stride.Launcher/Resources/switch-version.png b/sources/launcher/Stride.Launcher/Assets/Images/switch-version.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/switch-version.png rename to sources/launcher/Stride.Launcher/Assets/Images/switch-version.png diff --git a/sources/launcher/Stride.Launcher/Resources/twitch_24.png b/sources/launcher/Stride.Launcher/Assets/Images/twitch.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/twitch_24.png rename to sources/launcher/Stride.Launcher/Assets/Images/twitch.png diff --git a/sources/launcher/Stride.Launcher/Resources/update.png b/sources/launcher/Stride.Launcher/Assets/Images/update.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/update.png rename to sources/launcher/Stride.Launcher/Assets/Images/update.png diff --git a/sources/launcher/Stride.Launcher/Resources/upgrade-16.png b/sources/launcher/Stride.Launcher/Assets/Images/upgrade.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/upgrade-16.png rename to sources/launcher/Stride.Launcher/Assets/Images/upgrade.png diff --git a/sources/launcher/Stride.Launcher/Resources/visual-studio.png b/sources/launcher/Stride.Launcher/Assets/Images/visual-studio.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/visual-studio.png rename to sources/launcher/Stride.Launcher/Assets/Images/visual-studio.png diff --git a/sources/launcher/Stride.Launcher/Resources/xtwitter_24.png b/sources/launcher/Stride.Launcher/Assets/Images/xtwitter.png similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/xtwitter_24.png rename to sources/launcher/Stride.Launcher/Assets/Images/xtwitter.png diff --git a/sources/launcher/Stride.Launcher/Resources/Launcher.ico b/sources/launcher/Stride.Launcher/Assets/Launcher.ico similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/Launcher.ico rename to sources/launcher/Stride.Launcher/Assets/Launcher.ico diff --git a/sources/launcher/Stride.Launcher/Resources/Strings.Designer.cs b/sources/launcher/Stride.Launcher/Assets/Localization/Strings.Designer.cs similarity index 95% rename from sources/launcher/Stride.Launcher/Resources/Strings.Designer.cs rename to sources/launcher/Stride.Launcher/Assets/Localization/Strings.Designer.cs index 130b285152..440e09f904 100644 --- a/sources/launcher/Stride.Launcher/Resources/Strings.Designer.cs +++ b/sources/launcher/Stride.Launcher/Assets/Localization/Strings.Designer.cs @@ -8,7 +8,7 @@ // </auto-generated> //------------------------------------------------------------------------------ -namespace Stride.LauncherApp.Resources { +namespace Stride.Launcher.Assets.Localization { using System; @@ -39,7 +39,7 @@ internal Strings() { public static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stride.LauncherApp.Resources.Strings", typeof(Strings).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stride.Launcher.Assets.Localization.Strings", typeof(Strings).Assembly); resourceMan = temp; } return resourceMan; @@ -139,9 +139,9 @@ public static string ButtonForums { /// <summary> /// Looks up a localized string similar to Fork on GitHub. /// </summary> - public static string ButtonGithub { + public static string ButtonGitHub { get { - return ResourceManager.GetString("ButtonGithub", resourceCulture); + return ResourceManager.GetString("ButtonGitHub", resourceCulture); } } @@ -181,6 +181,28 @@ public static string ButtonSurvey { } } + /// <summary> + /// Looks up a localized string similar to Close anyway. + /// </summary> + public static string CloseAnyway { + get { + return ResourceManager.GetString("CloseAnyway", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to A Stride version is still being downloaded or installed. + /// + ///Closing the launcher now will cancel the operation. + /// + ///Do you want to close anyway?. + /// </summary> + public static string CloseLauncherInProgressMessage { + get { + return ResourceManager.GetString("CloseLauncherInProgressMessage", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Are you sure you wish to uninstall version {0}?. /// </summary> @@ -334,6 +356,15 @@ public static string InstallVersion { } } + /// <summary> + /// Looks up a localized string similar to Keep launcher open. + /// </summary> + public static string KeepLauncherOpen { + get { + return ResourceManager.GetString("KeepLauncherOpen", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Stride launcher. /// </summary> diff --git a/sources/launcher/Stride.Launcher/Resources/Strings.ja-JP.resx b/sources/launcher/Stride.Launcher/Assets/Localization/Strings.ja-JP.resx similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/Strings.ja-JP.resx rename to sources/launcher/Stride.Launcher/Assets/Localization/Strings.ja-JP.resx diff --git a/sources/launcher/Stride.Launcher/Resources/Strings.resx b/sources/launcher/Stride.Launcher/Assets/Localization/Strings.resx similarity index 96% rename from sources/launcher/Stride.Launcher/Resources/Strings.resx rename to sources/launcher/Stride.Launcher/Assets/Localization/Strings.resx index b75c9f282f..e001cedb8a 100644 --- a/sources/launcher/Stride.Launcher/Resources/Strings.resx +++ b/sources/launcher/Stride.Launcher/Assets/Localization/Strings.resx @@ -133,7 +133,7 @@ <value>Discuss about Stride</value> <comment>/!\ Text must be short</comment> </data> - <data name="ButtonGithub" xml:space="preserve"> + <data name="ButtonGitHub" xml:space="preserve"> <value>Fork on GitHub</value> <comment>/!\ Text must be short</comment> </data> @@ -448,4 +448,20 @@ Do you want to proceed? This will restart the launcher.</value> </data> + <data name="CloseAnyway" xml:space="preserve"> + <value>Close anyway</value> + <comment>Button label: closes the launcher even though an operation is in progress.</comment> + </data> + <data name="CloseLauncherInProgressMessage" xml:space="preserve"> + <value>A Stride version is still being downloaded or installed. + +Closing the launcher now will cancel the operation. + +Do you want to close anyway?</value> + <comment>Dialog body shown when the user tries to close the launcher while a download or install is running.</comment> + </data> + <data name="KeepLauncherOpen" xml:space="preserve"> + <value>Keep launcher open</value> + <comment>Button label: cancels the close and keeps the launcher open so the operation can finish.</comment> + </data> </root> \ No newline at end of file diff --git a/sources/launcher/Stride.Launcher/Resources/Urls.Designer.cs b/sources/launcher/Stride.Launcher/Assets/Localization/Urls.Designer.cs similarity index 97% rename from sources/launcher/Stride.Launcher/Resources/Urls.Designer.cs rename to sources/launcher/Stride.Launcher/Assets/Localization/Urls.Designer.cs index d61a3b7c68..0be689bd72 100644 --- a/sources/launcher/Stride.Launcher/Resources/Urls.Designer.cs +++ b/sources/launcher/Stride.Launcher/Assets/Localization/Urls.Designer.cs @@ -8,7 +8,7 @@ // </auto-generated> //------------------------------------------------------------------------------ -namespace Stride.LauncherApp.Resources { +namespace Stride.Launcher.Assets.Localization { using System; @@ -39,7 +39,7 @@ internal Urls() { public static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stride.LauncherApp.Resources.Urls", typeof(Urls).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stride.Launcher.Assets.Localization.Urls", typeof(Urls).Assembly); resourceMan = temp; } return resourceMan; @@ -108,9 +108,9 @@ public static string GettingStarted { /// <summary> /// Looks up a localized string similar to https://github.com/stride3d/stride/. /// </summary> - public static string Github { + public static string GitHub { get { - return ResourceManager.GetString("Github", resourceCulture); + return ResourceManager.GetString("GitHub", resourceCulture); } } diff --git a/sources/launcher/Stride.Launcher/Resources/Urls.ja-JP.resx b/sources/launcher/Stride.Launcher/Assets/Localization/Urls.ja-JP.resx similarity index 100% rename from sources/launcher/Stride.Launcher/Resources/Urls.ja-JP.resx rename to sources/launcher/Stride.Launcher/Assets/Localization/Urls.ja-JP.resx diff --git a/sources/launcher/Stride.Launcher/Resources/Urls.resx b/sources/launcher/Stride.Launcher/Assets/Localization/Urls.resx similarity index 99% rename from sources/launcher/Stride.Launcher/Resources/Urls.resx rename to sources/launcher/Stride.Launcher/Assets/Localization/Urls.resx index 8ab986f887..ec6e7404d4 100644 --- a/sources/launcher/Stride.Launcher/Resources/Urls.resx +++ b/sources/launcher/Stride.Launcher/Assets/Localization/Urls.resx @@ -134,7 +134,7 @@ <value>https://doc.stride3d.net/{0}/studio_getting_started_links.txt</value> <comment>{0}: the major version of Stride (eg. 1.2)</comment> </data> - <data name="Github" xml:space="preserve"> + <data name="GitHub" xml:space="preserve"> <value>https://github.com/stride3d/stride/</value> </data> <data name="Issues" xml:space="preserve"> @@ -161,4 +161,4 @@ <data name="VisualStudio" xml:space="preserve"> <value>https://visualstudio.microsoft.com/downloads</value> </data> -</root> +</root> \ No newline at end of file diff --git a/sources/launcher/Stride.Launcher/Constants.cs b/sources/launcher/Stride.Launcher/Constants.cs new file mode 100644 index 0000000000..b50f04c2ef --- /dev/null +++ b/sources/launcher/Stride.Launcher/Constants.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.Launcher; + +internal struct Names +{ + public const string GameStudio = nameof(GameStudio); + public const string Stride = nameof(Stride); + public const string Xenko = nameof(Xenko); +} + +internal struct GameStudioNames +{ + public const string Stride = $"{Names.Stride}.{Names.GameStudio}"; + public const string StrideAvalonia = $"{Names.Stride}.{Names.GameStudio}.Avalonia.Desktop"; + public const string Xenko = $"{Names.Xenko}.{Names.GameStudio}"; +} diff --git a/sources/launcher/Stride.Launcher/Crash/CrashReportArgs.cs b/sources/launcher/Stride.Launcher/Crash/CrashReportArgs.cs new file mode 100644 index 0000000000..5933afe724 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Crash/CrashReportArgs.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.Crash; + +internal enum CrashLocation +{ + Main, + UnhandledException +} + +internal record CrashReportArgs +{ + public required Exception Exception { get; set; } + public required CrashLocation Location { get; set; } + public string[] Logs { get; set; } = []; + public string? ThreadName { get; set; } +} diff --git a/sources/launcher/Stride.Launcher/Crash/CrashReportData.cs b/sources/launcher/Stride.Launcher/Crash/CrashReportData.cs new file mode 100644 index 0000000000..6784925069 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Crash/CrashReportData.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Text; +using System.Text.Json; + +namespace Stride.Crash; + +public sealed class CrashReportData +{ + public List<(string key, object? value)> Data = []; + + public object? this[string key] + { + get => Data.Find(p => p.key == key).value; + set + { + if (value == null) + return; + + int num = -1; + foreach (var current in Data) + { + if (current.key == key) + { + num = Data.IndexOf(current); + break; + } + } + if (num != -1) + { + Data[num] = (key, value); + } + else + { + Data.Add((key, value)); + } + } + } + + public string ToJson() => JsonSerializer.Serialize(Data.ToDictionary()); + + public override string ToString() + { + StringBuilder val = new(); + foreach (var (key, value) in Data) + { + val.AppendLine($"{key}: {value}"); + } + return val.ToString(); + } +} diff --git a/sources/launcher/Stride.Launcher/Crash/CrashReportViewModel.cs b/sources/launcher/Stride.Launcher/Crash/CrashReportViewModel.cs new file mode 100644 index 0000000000..d8f891399f --- /dev/null +++ b/sources/launcher/Stride.Launcher/Crash/CrashReportViewModel.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Diagnostics; +using System.Text; +using Stride.Core.Extensions; +using Stride.Core.Presentation.Avalonia.Services; +using Stride.Core.Presentation.Commands; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.ViewModels; + +namespace Stride.Crash.ViewModels; + +internal sealed class CrashReportViewModel : ViewModelBase +{ + private readonly string applicationName; + private readonly CancellationTokenSource exitToken; + private readonly Func<string?, Task> setClipboard; + + private bool isReportVisible; + + public CrashReportViewModel(string applicationName, CrashReportArgs args, Func<string?, Task> setClipboard, CancellationTokenSource exitToken) + : base(new ViewModelServiceProvider()) + { + this.applicationName = applicationName; + this.exitToken = exitToken; + this.setClipboard = setClipboard; + + var dispatcher = DispatcherService.Create(); + ServiceProvider.RegisterService(dispatcher); + ServiceProvider.RegisterService(new DialogService(dispatcher) { ApplicationName = applicationName }); + + Report = ComputeReport(args); + + CopyReportCommand = new AnonymousTaskCommand(ServiceProvider, OnCopyReport); + CloseCommand = new AnonymousCommand(ServiceProvider, OnClose); + OpenIssueCommand = new AnonymousTaskCommand(ServiceProvider, OnOpenIssue); + ViewReportCommand = new AnonymousCommand(ServiceProvider, OnViewReport); + } + + public string ApplicationName => applicationName; + + public bool IsReportVisible + { + get => isReportVisible; + set => SetValue(ref isReportVisible, value); + } + + public CrashReportData Report { get; } + + public ICommandBase CopyReportCommand { get; } + public ICommandBase CloseCommand { get; } + public ICommandBase OpenIssueCommand { get; } + public ICommandBase ViewReportCommand { get; } + + private void OnClose() + { + exitToken.Cancel(); + } + + private Task OnCopyReport() + { + return setClipboard.Invoke(Report.ToJson()); + } + + private async Task OnOpenIssue() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://github.com/stride3d/stride/issues/new?labels=bug&template=bug_report.md&", + UseShellExecute = true + }); + } + // FIXME: catch only specific exceptions? + catch (Exception) + { + DialogService.MainWindow!.Topmost = false; + // FIXME: localize resource string + await ServiceProvider.Get<IDialogService>().MessageBoxAsync("An error occurred while trying to open a web browser", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void OnViewReport() + { + IsReportVisible = true; + } + + private CrashReportData ComputeReport(CrashReportArgs args) + { + return new() + { + ["Application"] = applicationName, + ["ThreadName"] = args.ThreadName, +#if DEBUG + ["ProcessID"] = Environment.ProcessId, + ["CurrentDirectory"] = Environment.CurrentDirectory, +#endif + ["OsArch"] = Environment.Is64BitOperatingSystem ? "x64" : "x86", + ["OsVersion"] = Environment.OSVersion, + ["ProcessorCount"] = Environment.ProcessorCount, + ["Exception"] = args.Exception.FormatFull(), + ["LastLogs"] = FormatLogs(args.Logs), + }; + + static string FormatLogs(string[] logs) + { + var builder = new StringBuilder(); + for (var i = 0; i < logs.Length; i++) + { + var log = logs[i]; + builder.AppendLine($"{i + 1}: {log}"); + } + return builder.ToString(); + } + } +} diff --git a/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml b/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml new file mode 100644 index 0000000000..d2119585a6 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml @@ -0,0 +1,47 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:vm="using:Stride.Crash.ViewModels" + mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480" + x:Class="Stride.Crash.CrashReportWindow" + x:DataType="vm:CrashReportViewModel" + Icon="/Assets/CrashReportImage.png" + Title="Report your crash" + MinWidth="640" MinHeight="480" Width="800" Height="600" + WindowStartupLocation="CenterScreen"> + <Window.Styles> + <FluentTheme /> + </Window.Styles> + <DockPanel Margin="16"> + <StackPanel DockPanel.Dock="Top"> + <DockPanel> + <Image DockPanel.Dock="Left" + Width="64" Margin="0,0,8,8" + Source="/Assets/CrashReportImage.png"/> + <TextBlock TextWrapping="WrapWithOverflow"> + Unfortunately, <Run Text="{Binding ApplicationName, Mode=OneWay}" /> has crashed. + Please help us improve Stride by sending information about this crash through Github Issues. + </TextBlock> + </DockPanel> + <DockPanel LastChildFill="False" Margin="4"> + <Button DockPanel.Dock="Left" Margin="4 0" + Content="View Report" + Command="{Binding ViewReportCommand}"/> + <Button DockPanel.Dock="Left" Margin="8 0" + Content="Copy Report" + Command="{Binding CopyReportCommand}" /> + <!-- TODO: hyperlink style --> + <Button DockPanel.Dock="Right" Margin="8 0" + Content="New GitHub Issue" + Command="{Binding OpenIssueCommand}" /> + <Button DockPanel.Dock="Right" Margin="4 0" + Content="Close" IsDefault="True" + Command="{Binding CloseCommand}" /> + </DockPanel> + </StackPanel> + <TextBox IsReadOnly="True" + IsVisible="{Binding IsReportVisible, Mode=OneWay}" + Text="{Binding Report, Mode=OneWay}" /> + </DockPanel> +</Window> diff --git a/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml.cs b/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml.cs new file mode 100644 index 0000000000..9bdb26bfc5 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Crash/CrashReportWindow.axaml.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Controls; +using Avalonia.Input; + +namespace Stride.Crash; + +public partial class CrashReportWindow : Window +{ + public CrashReportWindow() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + Close(); + } + } +} diff --git a/sources/launcher/Stride.Launcher/CrashReport/CrashReportHelper.cs b/sources/launcher/Stride.Launcher/CrashReport/CrashReportHelper.cs deleted file mode 100644 index 11234431a6..0000000000 --- a/sources/launcher/Stride.Launcher/CrashReport/CrashReportHelper.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Globalization; -using System.Threading; -using System.Windows.Threading; -using Stride.Core.Extensions; -using Stride.Core.Windows; -using Stride.Editor.CrashReport; - -namespace Stride.LauncherApp.CrashReport -{ - public static class CrashReportHelper - { - private static bool terminating; - - public static void HandleException(Dispatcher dispatcher, Exception exception) - { - if (exception == null) - return; - - //prevent multiple crash reports - if (terminating) - return; - - terminating = true; - - var englishCulture = new CultureInfo("en-US"); - var crashLogThread = new Thread(CrashReport) { CurrentUICulture = englishCulture, CurrentCulture = englishCulture }; - crashLogThread.Start(new CrashReportArgs(exception, dispatcher)); - crashLogThread.Join(); - } - - [STAThread] - private static void CrashReport(object data) - { - var args = (CrashReportArgs)data; - - args.Dispatcher?.InvokeAsync(() => Thread.CurrentThread.Join()); - - SendReport(args.Exception.FormatFull()); - - Environment.Exit(0); - } - - private static void SendReport(string exceptionMessage) - { - var crashReport = new CrashReportData - { - ["Application"] = "Launcher", - ["CurrentDirectory"] = Environment.CurrentDirectory, - ["CommandArgs"] = string.Join(" ", AppHelper.GetCommandLineArgs()), - ["OsVersion"] = $"{Environment.OSVersion} {(Environment.Is64BitOperatingSystem ? "x64" : "x86")}", - ["ProcessorCount"] = Environment.ProcessorCount.ToString(), - ["Exception"] = exceptionMessage - }; - - var videoConfig = AppHelper.GetVideoConfig(); - foreach (var conf in videoConfig) - { - crashReport.Data.Add((conf.Key, conf.Value)); - } - - var reporter = new CrashReportWindow(crashReport, "Stride Launcher"); - reporter.ShowDialog(); - } - - private record CrashReportArgs(Exception Exception, Dispatcher Dispatcher); - } -} diff --git a/sources/launcher/Stride.Launcher/Launcher.cs b/sources/launcher/Stride.Launcher/Launcher.cs index 5cdd1cf925..b653b2f20a 100644 --- a/sources/launcher/Stride.Launcher/Launcher.cs +++ b/sources/launcher/Stride.Launcher/Launcher.cs @@ -1,238 +1,240 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Linq; using System.Reflection; -using System.Windows; -using Stride.Core.Annotations; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; using Stride.Core.Assets.Editor; using Stride.Core.Extensions; using Stride.Core.IO; using Stride.Core.Packages; +using Stride.Core.Presentation.Avalonia.Windows; +using Stride.Core.Presentation.Services; using Stride.Core.Windows; -using Stride.LauncherApp.CrashReport; -using Stride.LauncherApp.Services; -using Stride.Metrics; -using Stride.PrivacyPolicy; -using Dispatcher = System.Windows.Threading.Dispatcher; -using MessageBox = System.Windows.MessageBox; - -namespace Stride.LauncherApp -{ - /// <summary> - /// Entry point class of the Launcher. - /// </summary> - public static class Launcher - { - internal static FileLock Mutex; - internal static MetricsClient Metrics; +using Stride.Crash; +using Stride.Crash.ViewModels; +using Stride.Launcher.Services; - public const string ApplicationName = "Stride Launcher"; +namespace Stride.Launcher; - /// <summary> - /// The entry point function of the launcher. - /// </summary> - /// <returns>The process error code to return.</returns> - [STAThread] - public static int Main(string[] args) - { - // For now, we force culture to invariant one because GNU.Gettext.GettextResourceManager.GetSatelliteAssembly crashes when Assembly.Location is null - CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; +internal static class Launcher +{ + private static int terminating; + internal static FileLock? Mutex; - var arguments = ProcessArguments(args); - var result = ProcessAction(arguments); - return (int)result; - } + public const string ApplicationName = "Stride Launcher"; - /// <summary> - /// Returns path of Launcher (we can't use Assembly.GetEntryAssembly().Location in .NET Core, especially with self-publish). - /// </summary> - /// <returns></returns> - internal static string GetExecutablePath() + [STAThread] + public static LauncherErrorCode Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + try { - return Environment.ProcessPath; + var arguments = ProcessArguments(args); + return ProcessAction(arguments); } - - /// <summary> - /// Initializes a <see cref="NugetStore"/> instance assuming the entry point assembly is located at the root of the store. - /// </summary> - /// <returns>A new instance of <see cref="NugetStore"/>.</returns> - [NotNull] - internal static NugetStore InitializeNugetStore() + catch (Exception ex) { - var thisExeDirectory = new UFile(Assembly.GetEntryAssembly().Location).GetFullDirectory().ToOSPath(); - var store = new NugetStore(thisExeDirectory); - return store; + HandleException(ex, CrashLocation.Main); + return LauncherErrorCode.ErrorWhileRunningServer; } + } - /// <summary> - /// Displays a message to the user with OK and Cancel buttons, and returns whether the user cancelled. - /// </summary> - /// <param name="message">The message to display.</param> - /// <returns>True if the user answered OK, False otherwise.</returns> - internal static bool DisplayMessage(string message) - { - var result = MessageBox.Show(message, "Stride", MessageBoxButton.YesNo, MessageBoxImage.Information); - return result == MessageBoxResult.Yes; - } + internal static NugetStore InitializeNugetStore() + { + var thisExeDirectory = new UFile(Assembly.GetEntryAssembly()!.Location).GetFullDirectory().ToOSPath(); + var store = new NugetStore(thisExeDirectory); + return store; + } - /// <summary> - /// Displays an error message to the user with just an OK button. - /// </summary> - /// <param name="message">The message to display.</param> - internal static void DisplayError(string message) - { - MessageBox.Show(message, "Stride", MessageBoxButton.OK, MessageBoxImage.Error); - } + private static LauncherErrorCode ProcessAction(LauncherArguments args) + { + var result = LauncherErrorCode.UnknownError; - private static LauncherArguments ProcessArguments(string[] args) + try { - var result = new LauncherArguments - { - // Default action is to run the server - Actions = new List<LauncherArguments.ActionType> { LauncherArguments.ActionType.Run } - }; - - foreach (var arg in args) + // Ensure to create parent of lock directory. + Directory.CreateDirectory(EditorPath.DefaultTempPath); + using (Mutex = FileLock.TryLock(Path.Combine(EditorPath.DefaultTempPath, "launcher.lock"))) { - if (string.Equals(arg, "/Uninstall", StringComparison.InvariantCultureIgnoreCase)) + if (Mutex is not null) + { + Program.RunNewApp<App>(AppMain); + } + else { - // No other action possible when uninstalling. - result.Actions.Clear(); - result.Actions.Add(LauncherArguments.ActionType.Uninstall); + DisplayError("An instance of Stride Launcher is already running.", MessageBoxImage.Warning); + result = LauncherErrorCode.ServerAlreadyRunning; } } + } + catch (Exception e) + { + DisplayError($"Cannot start the instance of the Stride Launcher due to the following exception:\n{e.Message}", MessageBoxImage.Error); + result = LauncherErrorCode.UnknownError; + } + + return result; - return result; + CancellationToken AppMain(App app) + { + _ = AppMainAsync(app.cts); + return app.cts.Token; } - private static LauncherErrorCode ProcessAction(LauncherArguments args) + async Task AppMainAsync(CancellationTokenSource cts) { - var result = LauncherErrorCode.UnknownError; foreach (var action in args.Actions) { - switch (action) + result = action switch { - case LauncherArguments.ActionType.Run: - result = TryRun(); - break; - case LauncherArguments.ActionType.Uninstall: - result = Uninstall(); - break; - default: - // Unknown action - return LauncherErrorCode.UnknownError; - } - if (IsError(result)) - return result; + LauncherArguments.ActionType.Run => TryRun(cts), + LauncherArguments.ActionType.Uninstall => await UninstallAsync(cts), + _ => LauncherErrorCode.UnknownError,// Unknown action + }; + if (result < LauncherErrorCode.Success) + break; } - return result; } - private static LauncherErrorCode TryRun() + static void DisplayError(string message, MessageBoxImage image) { - try - { - // Ensure to create parent of lock directory. - Directory.CreateDirectory(EditorPath.DefaultTempPath); - using (Mutex = FileLock.TryLock(Path.Combine(EditorPath.DefaultTempPath, "launcher.lock"))) - { - if (Mutex != null) - { - return RunSingleInstance(false); - } + // Note: because we are not running from the main loop, we have to start a new app + Program.RunNewApp<MinimalApp>(AppMain); - MessageBox.Show("An instance of Stride Launcher is already running.", "Stride", MessageBoxButton.OK, MessageBoxImage.Exclamation); - return LauncherErrorCode.ServerAlreadyRunning; - } - } - catch (Exception e) + CancellationToken AppMain(Application app) { - DisplayError($"Cannot start the instance of the Stride Launcher due to the following exception:\n{e.Message}"); - return LauncherErrorCode.UnknownError; + var cts = new CancellationTokenSource(); + _ = MessageBox.ShowAsync(ApplicationName, message, IDialogService.GetButtons(MessageBoxButton.OK), image).ContinueWith(_ => cts.Cancel()); + return cts.Token; } } + } - private static LauncherErrorCode RunSingleInstance(bool shouldStartHidden) + private static LauncherArguments ProcessArguments(string[] args) + { + var result = new LauncherArguments + { + // Default action is to run the server + Actions = [LauncherArguments.ActionType.Run], + Args = args, + }; + + foreach (var arg in args) { - try + if (string.Equals(arg, "/Uninstall", StringComparison.InvariantCultureIgnoreCase)) { - // Only needed for Stride up to 2.x (and possibly 3.0): setup the StrideDir to make sure that it is passed to the underlying process (msbuild...etc.) - Environment.SetEnvironmentVariable("SiliconStudioStrideDir", AppDomain.CurrentDomain.BaseDirectory); - Environment.SetEnvironmentVariable("StrideDir", AppDomain.CurrentDomain.BaseDirectory); + // No other action possible when uninstalling. + result.Actions.Clear(); + result.Actions.Add(LauncherArguments.ActionType.Uninstall); + } + } - // We need to do that before starting recording metrics - // TODO: we do not display Privacy Policy anymore from launcher, because it's either accepted from installer or shown again when a new version of GS with new Privacy Policy starts. Might want to reconsider that after the 2.0 free period - PrivacyPolicyHelper.RestartApplication = SelfUpdater.RestartApplication; - PrivacyPolicyHelper.EnsurePrivacyPolicyStride40(); + return result; + } - // Install Metrics for the launcher - using (Metrics = new MetricsClient(CommonApps.StrideLauncherAppId)) - { - // HACK: force resolve the presentation assembly prior to initializing the app. This is to fix an issue with XAML themes. - // see issue PDX-2899 - var txt = new Core.Presentation.Controls.TextBox(); - GC.KeepAlive(txt); // prevent aggressive optimization from removing the line where we create the dummy TextBox. + private static LauncherErrorCode TryRun(CancellationTokenSource cts) + { + var mainWindow = ((IClassicDesktopStyleApplicationLifetime)Application.Current!.ApplicationLifetime!).MainWindow!; + mainWindow.Closed += (_, _) => cts.Cancel(); + mainWindow.Show(); + return LauncherErrorCode.Success; + } - var instance = new LauncherInstance(); - return instance.Run(shouldStartHidden); - } - } - catch (Exception exception) + private static async Task<LauncherErrorCode> UninstallAsync(CancellationTokenSource cts) + { + try + { + // Kill all running processes + var path = new UFile(Assembly.GetEntryAssembly()!.Location).GetFullDirectory().ToOSPath(); + if (!await UninstallHelper.CloseProcessesInPathAsync(DisplayMessageAsync, "Stride", path)) + return LauncherErrorCode.UninstallCancelled; // User cancelled + + // Uninstall packages (they might have uninstall actions) + var store = new NugetStore(path); + foreach (var package in store.MainPackageIds.SelectMany(store.GetLocalPackages).FilterStrideMainPackages().ToList()) { - CrashReportHelper.HandleException(Dispatcher.CurrentDispatcher, exception); - return LauncherErrorCode.ErrorWhileRunningServer; + await store.UninstallPackage(package, null); } - } - private static LauncherErrorCode Uninstall() - { - try + foreach (var remainingFiles in Directory.GetFiles(path, "*.lock").Concat(Directory.GetFiles(path, "*.old"))) { - // Kill all running processes - var path = new UFile(Assembly.GetEntryAssembly().Location).GetFullDirectory().ToOSPath(); - if (!UninstallHelper.CloseProcessesInPath(DisplayMessage, "Stride", path)) - return LauncherErrorCode.UninstallCancelled; // User cancelled - - // Uninstall packages (they might have uninstall actions) - var store = new NugetStore(path); - foreach (var package in store.MainPackageIds.SelectMany(store.GetLocalPackages).FilterStrideMainPackages().ToList()) + try { - store.UninstallPackage(package, null).Wait(); + File.Delete(remainingFiles); } - - foreach (var remainingFiles in Directory.GetFiles(path, "*.lock").Concat(Directory.GetFiles(path, "*.old"))) + catch (Exception e) { - try - { - File.Delete(remainingFiles); - } - catch (Exception e) - { - e.Ignore(); - } + e.Ignore(); } + } + + return LauncherErrorCode.Success; + } + catch (Exception) + { + return LauncherErrorCode.ErrorWhileUninstalling; + } + finally + { + await cts.CancelAsync(); + } - PrivacyPolicyHelper.RevokeAllPrivacyPolicy(); + static async Task<bool> DisplayMessageAsync(string message) + { + var result = await MessageBox.ShowAsync(ApplicationName, message, IDialogService.GetButtons(MessageBoxButton.YesNo), MessageBoxImage.Information); + return result == (int)MessageBoxResult.Yes; + } + } - return LauncherErrorCode.Success; - } - catch (Exception) + #region Crash + + private static void CrashReport(CrashReportArgs args) + { + Program.RunNewApp<MinimalApp>(AppMain); + + CancellationToken AppMain(Application app) + { + var cts = new CancellationTokenSource(); + var window = new CrashReportWindow { Topmost = true }; + window.DataContext = new CrashReportViewModel(ApplicationName, args, window.Clipboard!.SetTextAsync, cts); + window.Closed += (_, _) => cts.Cancel(); + if (!window.IsVisible) { - return LauncherErrorCode.ErrorWhileUninstalling; + window.Show(); } + ((IClassicDesktopStyleApplicationLifetime)app.ApplicationLifetime!).MainWindow = window; + return cts.Token; } + } - private static bool IsError(LauncherErrorCode errorCode) + private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.IsTerminating) { - return (int)errorCode < 0; + HandleException(e.ExceptionObject as Exception, CrashLocation.UnhandledException); } } -} + private static void HandleException(Exception? exception, CrashLocation location) + { + if (exception is null) return; + + // prevent multiple crash reports + if (Interlocked.CompareExchange(ref terminating, 1, 0) == 1) return; + var englishCulture = new CultureInfo("en-US"); + Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = englishCulture; + var reportArgs = new CrashReportArgs + { + Exception = exception, + Location = location, + ThreadName = Thread.CurrentThread.Name + }; + CrashReport(reportArgs); + } + + #endregion // Crash +} diff --git a/sources/launcher/Stride.Launcher/LauncherArguments.cs b/sources/launcher/Stride.Launcher/LauncherArguments.cs index 4741058206..0cc760f1a6 100644 --- a/sources/launcher/Stride.Launcher/LauncherArguments.cs +++ b/sources/launcher/Stride.Launcher/LauncherArguments.cs @@ -1,26 +1,27 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Collections.Generic; -namespace Stride.LauncherApp + +namespace Stride.Launcher; + +/// <summary> +/// A structure representing the arguments passed to the launcher process. +/// </summary> +internal struct LauncherArguments { /// <summary> - /// A structure representing the arguments passed to the launcher process. + /// An enum representing the type of action this process should perform. /// </summary> - internal struct LauncherArguments + public enum ActionType { - /// <summary> - /// An enum representing the type of action this process should perform. - /// </summary> - public enum ActionType - { - Run, - Uninstall, - } - - /// <summary> - /// The list of actions this process should perform. - /// </summary> - public List<ActionType> Actions; + Run, + Uninstall, } + + /// <summary> + /// The list of actions this process should perform. + /// </summary> + public List<ActionType> Actions; + + public string[] Args; } diff --git a/sources/launcher/Stride.Launcher/LauncherErrorCode.cs b/sources/launcher/Stride.Launcher/LauncherErrorCode.cs index 1e6298797a..a56d1ce583 100644 --- a/sources/launcher/Stride.Launcher/LauncherErrorCode.cs +++ b/sources/launcher/Stride.Launcher/LauncherErrorCode.cs @@ -1,29 +1,28 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -namespace Stride.LauncherApp +namespace Stride.Launcher; + +/// <summary> +/// An enum representing error codes returned by the launcher process. +/// </summary> +public enum LauncherErrorCode { - /// <summary> - /// An enum representing error codes returned by the launcher process. - /// </summary> - public enum LauncherErrorCode - { - Success = 0, + Success = 0, - // Non-error values (positive) - ServerAlreadyRunning = 1, + // Non-error values (positive) + ServerAlreadyRunning = 1, - // RunServer errors: -1 to -100 - ErrorWhileRunningServer = -1, // We don't have a more accurate error for the moment. - ErrorWhileInitializingServer = -2, + // RunServer errors: -1 to -100 + ErrorWhileRunningServer = -1, // We don't have a more accurate error for the moment. + ErrorWhileInitializingServer = -2, - // UpdateTargets errors: -101 to -200 - ErrorUpdatingTargetFiles = -101, // We don't have a more accurate error for the moment. + // UpdateTargets errors: -101 to -200 + ErrorUpdatingTargetFiles = -101, // We don't have a more accurate error for the moment. - // Uninstall errors: -201 to -300 - UninstallCancelled = -201, - ErrorWhileUninstalling = -202, // We don't have a more accurate error for the moment. + // Uninstall errors: -201 to -300 + UninstallCancelled = -201, + ErrorWhileUninstalling = -202, // We don't have a more accurate error for the moment. - UnknownError = -10000 - } + UnknownError = -10000 } diff --git a/sources/launcher/Stride.Launcher/LauncherInstance.cs b/sources/launcher/Stride.Launcher/LauncherInstance.cs deleted file mode 100644 index 1344c36236..0000000000 --- a/sources/launcher/Stride.Launcher/LauncherInstance.cs +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Threading; -using Stride.Core.Extensions; -using Stride.Core.Packages; -using Stride.Core.Presentation.Services; -using Stride.Core.Presentation.View; -using Stride.Core.Presentation.Windows; -using Stride.LauncherApp.Views; - -namespace Stride.LauncherApp -{ - /// <summary> - /// A class that manages a launcher instance, which must be single per user. It manages to show and hide windows, and keep the services alive - /// </summary> - internal class LauncherInstance - { - private IDispatcherService dispatcher; - private LauncherWindow launcherWindow; - private NugetStore store; - private App app; - - public LauncherErrorCode Run(bool shouldStartHidden) - { - dispatcher = new DispatcherService(Dispatcher.CurrentDispatcher); - - // Note: Initialize is responsible of displaying a message box in case of error - if (!Initialize()) - return LauncherErrorCode.ErrorWhileInitializingServer; - - app = new App { ShutdownMode = ShutdownMode.OnExplicitShutdown }; - app.InitializeComponent(); - - using (new WindowManager(Dispatcher.CurrentDispatcher)) - { - dispatcher.InvokeTask(() => ApplicationEntryPoint(shouldStartHidden)).Forget(); - app.Run(); - } - - return LauncherErrorCode.Success; - } - - internal void ShowMainWindow() - { - // This method can be invoked only from the dispatcher thread. - dispatcher.EnsureAccess(); - - if (launcherWindow == null) - { - // Create the window if we don't have it yet. - launcherWindow = new LauncherWindow(); - launcherWindow.Initialize(store); - launcherWindow.Closed += (s, e) => launcherWindow = null; - } - if (WindowManager.MainWindow == null) - { - // Show it if it's currently not visible - WindowManager.ShowMainWindow(launcherWindow); - } - else - { - // Otherwise just activate it. - if (launcherWindow.WindowState == WindowState.Minimized) - { - launcherWindow.WindowState = WindowState.Normal; - } - launcherWindow.Activate(); - } - - } - - internal void CloseMainWindow() - { - // This method can be invoked only from the dispatcher thread. - dispatcher.EnsureAccess(); - - launcherWindow.Close(); - } - - internal async void ForceExit() - { - await Shutdown(); - } - - /// <summary> - /// Setup the Launcher's service interface to handle IPC communications. - /// </summary> - private bool Initialize() - { - // Setup the Nuget store - store = Launcher.InitializeNugetStore(); - - return true; - } - - private async Task Shutdown() - { - // Close view elements first - launcherWindow?.Close(); - - // Yield so that tasks that were awaiting can complete and the server can gracefully terminate - await Task.Yield(); - - // Terminate the server and the app at last - app.Shutdown(); - } - - private async Task ApplicationEntryPoint(bool shouldStartHidden) - { - var authenticated = await CheckAndPromptCredentials(); - - if (!authenticated) - Shutdown(); - - if (!shouldStartHidden) - ShowMainWindow(); - } - - /// <summary> - /// Ask users for his/her credentials if no session is authenticated or has expired. - /// </summary> - /// <returns><c>true</c> if session was validated, <c>false</c> otherwise.</returns> - private async Task<bool> CheckAndPromptCredentials() - { - // This method can be invoked only from the dispatcher thread. - dispatcher.EnsureAccess(); - - // Return whether or not we're now successfully authenticated. - return true; - } - - private void RequestShowMainWindow() - { - dispatcher.EnsureAccess(false); - dispatcher.Invoke(ShowMainWindow); - } - - private void RequestCloseMainWindow() - { - dispatcher.EnsureAccess(false); - dispatcher.Invoke(CloseMainWindow); - } - - private bool RequestCheckAndPromptCredentials() - { - dispatcher.EnsureAccess(false); - return dispatcher.InvokeTask(CheckAndPromptCredentials).Result; - } - } -} diff --git a/sources/launcher/Stride.Launcher/PackageFilterExtensions.cs b/sources/launcher/Stride.Launcher/PackageFilterExtensions.cs index 5e5e8ceb14..547af57b1b 100644 --- a/sources/launcher/Stride.Launcher/PackageFilterExtensions.cs +++ b/sources/launcher/Stride.Launcher/PackageFilterExtensions.cs @@ -1,18 +1,18 @@ -using System.Collections.Generic; -using System.Linq; +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + using Stride.Core; using Stride.Core.Packages; -namespace Stride.LauncherApp +namespace Stride.Launcher; + +internal static class PackageFilterExtensions { - static class PackageFilterExtensions + public static IEnumerable<T> FilterStrideMainPackages<T>(this IEnumerable<T> packages) where T : NugetPackage { - public static IEnumerable<T> FilterStrideMainPackages<T>(this IEnumerable<T> packages) where T : NugetPackage - { - // Stride up to 3.0 package is Xenko, 3.x is Xenko.GameStudio, then Stride.GameStudio - return packages.Where(x => (x.Id == "Xenko" && x.Version < new PackageVersion(3, 1, 0, 0)) - || (x.Id == "Xenko.GameStudio" && x.Version < new PackageVersion(4, 0, 0, 0)) - || (x.Id == "Stride.GameStudio")); - } + // Stride up to 3.0 package is Xenko, 3.x is Xenko.GameStudio, then Stride.GameStudio + return packages.Where(x => (x.Id is Names.Xenko && x.Version < new PackageVersion(3, 1, 0, 0)) + || (x.Id is GameStudioNames.Xenko && x.Version < new PackageVersion(4, 0, 0, 0)) + || (x.Id is GameStudioNames.Stride or GameStudioNames.StrideAvalonia)); } } diff --git a/sources/launcher/Stride.Launcher/PrerequisitesValidator.cs b/sources/launcher/Stride.Launcher/PrerequisitesValidator.cs deleted file mode 100644 index 06dcb59872..0000000000 --- a/sources/launcher/Stride.Launcher/PrerequisitesValidator.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Windows.Forms; -using Microsoft.Win32; -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -namespace Stride.LauncherApp -{ - internal static class PrerequisitesValidator - { - private const string LauncherPrerequisites = @"Prerequisites\launcher-prerequisites.exe"; - - private static bool CheckDotNet4Version(int requiredVersion) - { - // Check for .NET v4 version - using (var ndpKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full")) - { - if (ndpKey == null) - return false; - - int releaseKey = Convert.ToInt32(ndpKey.GetValue("Release")); - if (releaseKey < requiredVersion) - return false; - } - - return true; - } - - private static bool ValidateDotNet4Version(StringBuilder prerequisiteLog) - { - var result = true; - - // Check for .NET 4.7.2+ - // Note: it should now always be the case since renaming: Stride launcher is a separate forced setup to run, and it checks for 4.7.2. - // Still keeping code for future framework updates - if (!CheckDotNet4Version(461808)) - { - prerequisiteLog.AppendLine("- .NET framework 4.7.2"); - result = false; - } - - // Everything passed - return result; - } - - internal static void Validate(string[] args) - { - // Check prerequisites - var prerequisiteLog = new StringBuilder(); - var prerequisitesFailedOnce = false; - while (!ValidateDotNet4Version(prerequisiteLog)) - { - prerequisitesFailedOnce = true; - - // Check if launcher prerequisite installer exists - if (!File.Exists(LauncherPrerequisites)) - { - MessageBox.Show($"Some prerequisites are missing, but no prerequisite installer was found!\n\n{prerequisiteLog}\n\nPlease install them manually or report the problem.", "Prerequisite error", MessageBoxButtons.OK); - return; - } - - // One of the prerequisite failed, launch the prerequisite installer - var prerequisitesApproved = MessageBox.Show($"Some prerequisites are missing, do you want to install them?\n\n{prerequisiteLog}", "Install missing prerequisites?", MessageBoxButtons.OKCancel); - if (prerequisitesApproved == DialogResult.Cancel) - return; - - try - { - var prerequisitesInstallerProcess = Process.Start(LauncherPrerequisites); - if (prerequisitesInstallerProcess == null) - { - MessageBox.Show($"There was an error running the prerequisite installer {LauncherPrerequisites}.", "Prerequisite error", MessageBoxButtons.OK); - return; - } - - prerequisitesInstallerProcess.WaitForExit(); - } - catch - { - MessageBox.Show($"There was an error running the prerequisite installer {LauncherPrerequisites}.", "Prerequisite error", MessageBoxButtons.OK); - return; - } - prerequisiteLog.Length = 0; - } - - if (!prerequisitesFailedOnce) - { - return; - } - // If prerequisites failed at least once, we want to restart ourselves to run with proper .NET framework - var exeLocation = Launcher.GetExecutablePath(); - if (File.Exists(exeLocation)) - { - // Forward arguments - for (int i = 0; i < args.Length; ++i) - { - // Quote arguments with spaces - if (args[i].IndexOf(' ') != -1) - args[i] = '\"' + args[i] + '\"'; - } - var arguments = string.Join(" ", args); - - // Start process - Process.Start(exeLocation, arguments); - } - return; - } - } -} diff --git a/sources/launcher/Stride.Launcher/Program.cs b/sources/launcher/Stride.Launcher/Program.cs index a23e2c30de..5089a66ffb 100644 --- a/sources/launcher/Stride.Launcher/Program.cs +++ b/sources/launcher/Stride.Launcher/Program.cs @@ -1,16 +1,60 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -namespace Stride.LauncherApp +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; + +namespace Stride.Launcher; + +internal sealed class Program { - static class Program + [STAThread] + private static int Main(string[] args) { - [STAThread] - private static void Main(string[] args) + return (int)Launcher.Main(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure<App>() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + /// <summary> + /// Returns path of Launcher (we can't use Assembly.GetEntryAssembly().Location in .NET Core, especially with self-publish). + /// </summary> + /// <returns></returns> + internal static string? GetExecutablePath() => Environment.ProcessPath; + + internal static void RunNewApp<TApp>(Func<TApp, CancellationToken> appMain, string[]? args = null) + where TApp : Application, new() + { + // Note: we need a new app because the main one may be already shutting down + var appBuilder = AppBuilder.Configure<TApp>() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + if (Application.Current is null) { - PrerequisitesValidator.Validate(args); - Launcher.Main(args); + appBuilder = appBuilder + .SetupWithClassicDesktopLifetime(args ?? [], x => x.ShutdownMode = ShutdownMode.OnExplicitShutdown); + var app = appBuilder.Instance!; + app.Run(appMain((TApp)app)); + } + else + { + Dispatcher.UIThread.Invoke(() => + { + // First hide the main window + ((IClassicDesktopStyleApplicationLifetime?)Application.Current.ApplicationLifetime)?.MainWindow?.Hide(); + + var app = appBuilder.Instance!; + app.Run(appMain((TApp)app)); + }); } } } diff --git a/sources/launcher/Stride.Launcher/Properties/AssemblyInfo.cs b/sources/launcher/Stride.Launcher/Properties/AssemblyInfo.cs deleted file mode 100644 index 10aef5c897..0000000000 --- a/sources/launcher/Stride.Launcher/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Resources; -using System.Runtime.InteropServices; - - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -//[assembly: AssemblyFileVersion("1.0.0.0")] - -[assembly: NeutralResourcesLanguage("en-US")] diff --git a/sources/launcher/Stride.Launcher/Properties/PublishProfiles/FolderProfile.pubxml b/sources/launcher/Stride.Launcher/Properties/PublishProfiles/FolderProfile.pubxml index 19b6fe681a..75a12e28ec 100644 --- a/sources/launcher/Stride.Launcher/Properties/PublishProfiles/FolderProfile.pubxml +++ b/sources/launcher/Stride.Launcher/Properties/PublishProfiles/FolderProfile.pubxml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <!-- https://go.microsoft.com/fwlink/?LinkID=208121. --> @@ -8,12 +8,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121. <Platform>Any CPU</Platform> <PublishDir>bin\Release\publish\</PublishDir> <PublishProtocol>FileSystem</PublishProtocol> - <TargetFramework>net10.0-windows</TargetFramework> <SelfContained>true</SelfContained> - <RuntimeIdentifier>win-x64</RuntimeIdentifier> <PublishSingleFile>true</PublishSingleFile> <PublishReadyToRun>false</PublishReadyToRun> <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <PublishTrimmed>false</PublishTrimmed> </PropertyGroup> -</Project> \ No newline at end of file +</Project> diff --git a/sources/launcher/Stride.Launcher/Resources/Robot.jpg b/sources/launcher/Stride.Launcher/Resources/Robot.jpg deleted file mode 100644 index e1044581cd..0000000000 --- a/sources/launcher/Stride.Launcher/Resources/Robot.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:157ed9116a53ecfe5738b6c2b695a3e6844ca0fa97cb840719d56ca7aafc6cce -size 109714 diff --git a/sources/launcher/Stride.Launcher/Services/GameStudioSettings.cs b/sources/launcher/Stride.Launcher/Services/GameStudioSettings.cs index d960721895..d17b4deafa 100644 --- a/sources/launcher/Stride.Launcher/Services/GameStudioSettings.cs +++ b/sources/launcher/Stride.Launcher/Services/GameStudioSettings.cs @@ -1,9 +1,6 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; + using Stride.Core.Assets.Editor; using Stride.Core.Extensions; using Stride.Core.IO; @@ -11,145 +8,144 @@ using Stride.Core.Settings; using Stride.Core.Yaml; -namespace Stride.LauncherApp.Services +namespace Stride.Launcher.Services; + +public static class GameStudioSettings { - public static class GameStudioSettings - { - private static readonly SettingsProfile GameStudioProfile; + private static readonly SettingsProfile GameStudioProfile; - private static readonly SettingsContainer InternalSettingsContainer = new SettingsContainer(); + private static readonly SettingsContainer InternalSettingsContainer = new(); - private static readonly SettingsContainer GameStudioSettingsContainer = new SettingsContainer(); + private static readonly SettingsContainer GameStudioSettingsContainer = new(); - private static readonly SettingsKey<MRUDictionary> MostRecentlyUsedSessionsKey = new SettingsKey<MRUDictionary>("Internal/MostRecentlyUsedSessions", InternalSettingsContainer, () => new MRUDictionary()); + private static readonly SettingsKey<MRUDictionary> MostRecentlyUsedSessionsKey = new("Internal/MostRecentlyUsedSessions", InternalSettingsContainer, () => new MRUDictionary()); - private static readonly SettingsKey<string> StoreCrashEmail = new SettingsKey<string>("Interface/StoreCrashEmail", GameStudioSettingsContainer, ""); + private static readonly SettingsKey<string> StoreCrashEmail = new("Interface/StoreCrashEmail", GameStudioSettingsContainer, ""); - private static readonly object LockObject = new object(); + private static readonly object LockObject = new(); - private static readonly MostRecentlyUsedFileCollection MRU; + private static readonly MostRecentlyUsedFileCollection MRU; - private static IReadOnlyCollection<UFile> mostRecentlyUsed; + private static IReadOnlyCollection<UFile>? mostRecentlyUsed; - private static bool updating; + private static bool updating; - static GameStudioSettings() - { - MRU = new MostRecentlyUsedFileCollection(() => InternalSettingsContainer.LoadSettingsProfile(GetLatestInternalConfigPath(), false, null, false), MostRecentlyUsedSessionsKey, () => InternalSettingsContainer.SaveSettingsProfile(InternalSettingsContainer.CurrentProfile, GetLatestInternalConfigPath())); - MostRecentlyUsedSessionsKey.FallbackDeserializers.Add(LegacyMRUDeserializer); - InternalSettingsContainer.LoadSettingsProfile(GetLatestInternalConfigPath(), true); - InternalSettingsContainer.CurrentProfile.MonitorFileModification = true; - InternalSettingsContainer.CurrentProfile.FileModified += (sender, e) => { GameStudioSettingsFileChanged(sender, e); }; - GameStudioProfile = GameStudioSettingsContainer.LoadSettingsProfile(GetLatestGameStudioConfigPath(), true); - UpdateMostRecentlyUsed(); - } + static GameStudioSettings() + { + MRU = new MostRecentlyUsedFileCollection(() => InternalSettingsContainer.LoadSettingsProfile(GetLatestInternalConfigPath(), false, null, false), MostRecentlyUsedSessionsKey, () => InternalSettingsContainer.SaveSettingsProfile(InternalSettingsContainer.CurrentProfile, GetLatestInternalConfigPath())); + MostRecentlyUsedSessionsKey.FallbackDeserializers.Add(LegacyMRUDeserializer); + InternalSettingsContainer.LoadSettingsProfile(GetLatestInternalConfigPath(), true); + InternalSettingsContainer.CurrentProfile.MonitorFileModification = true; + InternalSettingsContainer.CurrentProfile.FileModified += GameStudioSettingsFileChanged; + GameStudioProfile = GameStudioSettingsContainer.LoadSettingsProfile(GetLatestGameStudioConfigPath(), true); + UpdateMostRecentlyUsed(); + } - public static event EventHandler<EventArgs> RecentProjectsUpdated; + public static event EventHandler<EventArgs>? RecentProjectsUpdated; - public static string CrashReportEmail + public static string CrashReportEmail + { + get { - get + try { - try - { - lock (LockObject) - { - GameStudioSettingsContainer.ReloadSettingsProfile(GameStudioProfile); - return StoreCrashEmail.GetValue(); - } - } - catch (Exception) + lock (LockObject) { - return ""; + GameStudioSettingsContainer.ReloadSettingsProfile(GameStudioProfile); + return StoreCrashEmail.GetValue(); } } - set + catch (Exception) { - try - { - lock (LockObject) - { - GameStudioSettingsContainer.ReloadSettingsProfile(GameStudioProfile); - StoreCrashEmail.SetValue(value); - GameStudioSettingsContainer.SaveSettingsProfile(GameStudioProfile, GetLatestGameStudioConfigPath()); - } - } - catch (Exception e) - { - e.Ignore(); - } + return ""; } } - - public static IReadOnlyCollection<UFile> GetMostRecentlyUsed() + set { - List<UFile> result; - lock (LockObject) + try { - result = new List<UFile>(mostRecentlyUsed); + lock (LockObject) + { + GameStudioSettingsContainer.ReloadSettingsProfile(GameStudioProfile); + StoreCrashEmail.SetValue(value); + GameStudioSettingsContainer.SaveSettingsProfile(GameStudioProfile, GetLatestGameStudioConfigPath()); + } } - return result; - } - - private static void GameStudioSettingsFileChanged(object sender, FileModifiedEventArgs e) - { - e.ReloadFile = true; - UpdateMostRecentlyUsed(); - } - - public static void RemoveMostRecentlyUsed(UFile filePath, string strideVersion) - { - lock (LockObject) + catch (Exception e) { - MRU.RemoveFile(filePath, strideVersion); - UpdateMostRecentlyUsed(); + e.Ignore(); } } + } - private static void UpdateMostRecentlyUsed() + public static IReadOnlyCollection<UFile> GetMostRecentlyUsed() + { + List<UFile> result; + lock (LockObject) { - if (updating) - return; - - lock (LockObject) - { - updating = true; - MRU.LoadFromSettings(); - updating = false; - mostRecentlyUsed = MRU.MostRecentlyUsedFiles.Select(x => x.FilePath).ToList(); - } - RecentProjectsUpdated?.Invoke(null, EventArgs.Empty); + result = new(mostRecentlyUsed ?? Enumerable.Empty<UFile>()); } + return result; + } - private static string GetLatestInternalConfigPath() - { - return GetInternalConfigPaths().FirstOrDefault(File.Exists) ?? EditorPath.InternalConfigPath; - } + private static void GameStudioSettingsFileChanged(object? sender, FileModifiedEventArgs e) + { + e.ReloadFile = true; + UpdateMostRecentlyUsed(); + } - private static string GetLatestGameStudioConfigPath() + public static void RemoveMostRecentlyUsed(UFile filePath, string strideVersion) + { + lock (LockObject) { - return GetGameStudioConfigPaths().FirstOrDefault(File.Exists) ?? EditorPath.EditorConfigPath; + MRU.RemoveFile(filePath, strideVersion); + UpdateMostRecentlyUsed(); } + } - private static IEnumerable<string> GetInternalConfigPaths() - { - yield return EditorPath.InternalConfigPath; - } + private static void UpdateMostRecentlyUsed() + { + if (updating) + return; - private static IEnumerable<string> GetGameStudioConfigPaths() + lock (LockObject) { - yield return EditorPath.EditorConfigPath; + updating = true; + MRU.LoadFromSettings(); + updating = false; + mostRecentlyUsed = MRU.MostRecentlyUsedFiles.Select(x => x.FilePath).ToList(); } + RecentProjectsUpdated?.Invoke(null, EventArgs.Empty); + } + + private static string GetLatestInternalConfigPath() + { + return GetInternalConfigPaths().FirstOrDefault(File.Exists) ?? EditorPath.InternalConfigPath; + } + + private static string GetLatestGameStudioConfigPath() + { + return GetGameStudioConfigPaths().FirstOrDefault(File.Exists) ?? EditorPath.EditorConfigPath; + } + + private static IEnumerable<string> GetInternalConfigPaths() + { + yield return EditorPath.InternalConfigPath; + } + + private static IEnumerable<string> GetGameStudioConfigPaths() + { + yield return EditorPath.EditorConfigPath; + } - private static object LegacyMRUDeserializer(EventReader eventReader) + private static object LegacyMRUDeserializer(EventReader eventReader) + { + const string legacyVersion = "1.3"; + var mru = (List<UFile>)SettingsYamlSerializer.Default.Deserialize(eventReader, typeof(List<UFile>)); + var initialTimestamp = DateTime.UtcNow.Ticks; + return new Dictionary<string, List<MostRecentlyUsedFile>> { - const string legacyVersion = "1.3"; - var mru = (List<UFile>)SettingsYamlSerializer.Default.Deserialize(eventReader, typeof(List<UFile>)); - var initialTimestamp = DateTime.UtcNow.Ticks; - return new Dictionary<string, List<MostRecentlyUsedFile>> - { - { legacyVersion, mru.Select(x => new MostRecentlyUsedFile(x) { Timestamp = initialTimestamp-- }).ToList() } - }; - } + { legacyVersion, mru.Select(x => new MostRecentlyUsedFile(x) { Timestamp = initialTimestamp-- }).ToList() } + }; } } diff --git a/sources/launcher/Stride.Launcher/Services/ILauncherSettingsService.cs b/sources/launcher/Stride.Launcher/Services/ILauncherSettingsService.cs new file mode 100644 index 0000000000..af25dfef2a --- /dev/null +++ b/sources/launcher/Stride.Launcher/Services/ILauncherSettingsService.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Stride.Core.IO; + +namespace Stride.Launcher.Services; + +public interface ILauncherSettingsService +{ + bool CloseLauncherAutomatically { get; set; } + string ActiveVersion { get; set; } + string PreferredFramework { get; set; } + int CurrentTab { get; set; } + IReadOnlyCollection<UDirectory> DeveloperVersions { get; } + bool IsTaskCompleted(string taskName); + /// <summary>Marks the task as completed and persists immediately (same contract as <see cref="LauncherSettings.MarkTaskCompleted"/>).</summary> + void MarkTaskCompleted(string taskName); + void Save(); +} diff --git a/sources/launcher/Stride.Launcher/Services/LauncherSettings.cs b/sources/launcher/Stride.Launcher/Services/LauncherSettings.cs index 62ecdd313f..b120688ab9 100644 --- a/sources/launcher/Stride.Launcher/Services/LauncherSettings.cs +++ b/sources/launcher/Stride.Launcher/Services/LauncherSettings.cs @@ -1,63 +1,78 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Collections.Generic; -using System.IO; -using System.Linq; + using Stride.Core.Assets.Editor; using Stride.Core.IO; using Stride.Core.Settings; -namespace Stride.LauncherApp.Services +namespace Stride.Launcher.Services; + +public static class LauncherSettings { - public static class LauncherSettings + private static readonly SettingsContainer SettingsContainer = new(); + + private static readonly SettingsKey<bool> CloseLauncherAutomaticallyKey = new("Internal/Launcher/CloseLauncherAutomatically", SettingsContainer, false); + private static readonly SettingsKey<string> ActiveVersionKey = new("Internal/Launcher/ActiveVersion", SettingsContainer, ""); + private static readonly SettingsKey<string> PreferredFrameworkKey = new("Internal/Launcher/PreferredFramework", SettingsContainer, "net10.0"); + private static readonly SettingsKey<int> CurrentTabKey = new("Internal/Launcher/CurrentTabSessions", SettingsContainer, 0); + private static readonly SettingsKey<List<UDirectory>> DeveloperVersionsKey = new("Internal/Launcher/DeveloperVersions", SettingsContainer, () => new List<UDirectory>()); + private static readonly SettingsKey<List<string>> CompletedTasksKey = new("Internal/Launcher/CompletedTasks", SettingsContainer, () => new List<string>()); + + private static readonly string LauncherConfigPath = Path.Combine(EditorPath.UserDataPath, "LauncherSettings.conf"); + + private static List<string> completedTasks = []; + + static LauncherSettings() { - private static readonly SettingsContainer SettingsContainer = new SettingsContainer(); + SettingsContainer.LoadSettingsProfile(GetLatestLauncherConfigPath(), true); + CloseLauncherAutomatically = CloseLauncherAutomaticallyKey.GetValue(); + ActiveVersion = ActiveVersionKey.GetValue(); + PreferredFramework = PreferredFrameworkKey.GetValue(); + CurrentTab = CurrentTabKey.GetValue(); + DeveloperVersions = DeveloperVersionsKey.GetValue(); + completedTasks = CompletedTasksKey.GetValue(); + } - private static readonly SettingsKey<bool> CloseLauncherAutomaticallyKey = new SettingsKey<bool>("Internal/Launcher/CloseLauncherAutomatically", SettingsContainer, false); - private static readonly SettingsKey<string> ActiveVersionKey = new SettingsKey<string>("Internal/Launcher/ActiveVersion", SettingsContainer, ""); - private static readonly SettingsKey<string> PreferredFrameworkKey = new SettingsKey<string>("Internal/Launcher/PreferredFramework", SettingsContainer, "net10.0"); - private static readonly SettingsKey<int> CurrentTabKey = new SettingsKey<int>("Internal/Launcher/CurrentTabSessions", SettingsContainer, 0); - private static readonly SettingsKey<List<UDirectory>> DeveloperVersionsKey = new SettingsKey<List<UDirectory>>("Internal/Launcher/DeveloperVersions", SettingsContainer, () => new List<UDirectory>()); + public static void Save() + { + CloseLauncherAutomaticallyKey.SetValue(CloseLauncherAutomatically); + ActiveVersionKey.SetValue(ActiveVersion); + PreferredFrameworkKey.SetValue(PreferredFramework); + CurrentTabKey.SetValue(CurrentTab); + CompletedTasksKey.SetValue(completedTasks); + SettingsContainer.SaveSettingsProfile(SettingsContainer.CurrentProfile, LauncherConfigPath); + } - private static readonly string LauncherConfigPath = Path.Combine(EditorPath.UserDataPath, "LauncherSettings.conf"); + public static IReadOnlyCollection<UDirectory> DeveloperVersions { get; private set; } - static LauncherSettings() - { - SettingsContainer.LoadSettingsProfile(GetLatestLauncherConfigPath(), true); - CloseLauncherAutomatically = CloseLauncherAutomaticallyKey.GetValue(); - ActiveVersion = ActiveVersionKey.GetValue(); - PreferredFramework = PreferredFrameworkKey.GetValue(); - CurrentTab = CurrentTabKey.GetValue(); - DeveloperVersions = DeveloperVersionsKey.GetValue(); - } + public static bool CloseLauncherAutomatically { get; set; } - public static void Save() - { - CloseLauncherAutomaticallyKey.SetValue(CloseLauncherAutomatically); - ActiveVersionKey.SetValue(ActiveVersion); - PreferredFrameworkKey.SetValue(PreferredFramework); - CurrentTabKey.SetValue(CurrentTab); - SettingsContainer.SaveSettingsProfile(SettingsContainer.CurrentProfile, LauncherConfigPath); - } + public static string ActiveVersion { get; set; } - public static IReadOnlyCollection<UDirectory> DeveloperVersions { get; private set; } - - public static bool CloseLauncherAutomatically { get; set; } - - public static string ActiveVersion { get; set; } + public static string PreferredFramework { get; set; } - public static string PreferredFramework { get; set; } - - public static int CurrentTab { get; set; } + public static int CurrentTab { get; set; } - private static string GetLatestLauncherConfigPath() - { - return GetLauncherConfigPaths().FirstOrDefault(File.Exists) ?? LauncherConfigPath; - } + public static IReadOnlyCollection<string> CompletedTasks => completedTasks; - private static IEnumerable<string> GetLauncherConfigPaths() + public static bool IsTaskCompleted(string taskName) => completedTasks.Contains(taskName); + + public static void MarkTaskCompleted(string taskName) + { + if (!completedTasks.Contains(taskName)) { - yield return LauncherConfigPath; + completedTasks.Add(taskName); + Save(); } } + + private static string GetLatestLauncherConfigPath() + { + return GetLauncherConfigPaths().FirstOrDefault(File.Exists) ?? LauncherConfigPath; + } + + private static IEnumerable<string> GetLauncherConfigPaths() + { + yield return LauncherConfigPath; + } } diff --git a/sources/launcher/Stride.Launcher/Services/LauncherSettingsService.cs b/sources/launcher/Stride.Launcher/Services/LauncherSettingsService.cs new file mode 100644 index 0000000000..b833429cf6 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Services/LauncherSettingsService.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Stride.Core.IO; + +namespace Stride.Launcher.Services; + +internal sealed class LauncherSettingsService : ILauncherSettingsService +{ + public bool CloseLauncherAutomatically + { + get => LauncherSettings.CloseLauncherAutomatically; + set => LauncherSettings.CloseLauncherAutomatically = value; + } + + public string ActiveVersion + { + get => LauncherSettings.ActiveVersion; + set => LauncherSettings.ActiveVersion = value; + } + + public string PreferredFramework + { + get => LauncherSettings.PreferredFramework; + set => LauncherSettings.PreferredFramework = value; + } + + public int CurrentTab + { + get => LauncherSettings.CurrentTab; + set => LauncherSettings.CurrentTab = value; + } + + public IReadOnlyCollection<UDirectory> DeveloperVersions => LauncherSettings.DeveloperVersions; + + public bool IsTaskCompleted(string taskName) => LauncherSettings.IsTaskCompleted(taskName); + + public void MarkTaskCompleted(string taskName) => LauncherSettings.MarkTaskCompleted(taskName); + + public void Save() => LauncherSettings.Save(); +} diff --git a/sources/launcher/Stride.Launcher/Services/MetricsHelper.cs b/sources/launcher/Stride.Launcher/Services/MetricsHelper.cs deleted file mode 100644 index 3a52e74cc9..0000000000 --- a/sources/launcher/Stride.Launcher/Services/MetricsHelper.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using Stride.Core.Extensions; - -namespace Stride.LauncherApp.Services -{ - internal static class MetricsHelper - { - public static void NotifyDownloadStarting(string packageName, string packageVersion) => NotifyDownloadEvent(packageName, packageVersion, "DownloadStarting"); - - public static void NotifyDownloadFailed(string packageName, string packageVersion) => NotifyDownloadEvent(packageName, packageVersion, "DownloadFailed"); - - public static void NotifyDownloadCompleted(string packageName, string packageVersion) => NotifyDownloadEvent(packageName, packageVersion, "DownloadCompleted"); - - private static void NotifyDownloadEvent(string packageName, string packageVersion, string downloadEvent) - { - try - { - var downloadInfo = BuildMessage(DateTime.UtcNow, downloadEvent, packageName, packageVersion); - Launcher.Metrics?.DownloadPackage(downloadInfo); - } - catch (Exception e) - { - e.Ignore(); - } - } - - private static string BuildMessage(DateTime dateTime, string downloadEvent, string packageName, string packageVersion) - { - var timestamp = (long)(dateTime - new DateTime(1970, 1, 1)).TotalSeconds; - return $"{Escape(packageName)}||{Escape(packageVersion)}||{downloadEvent}||{timestamp}"; - } - - private static string Escape(string s) => s.Replace("|", @"\|"); - } -} diff --git a/sources/launcher/Stride.Launcher/Services/SelfUpdater.cs b/sources/launcher/Stride.Launcher/Services/SelfUpdater.cs index 5013edddd0..3428842400 100644 --- a/sources/launcher/Stride.Launcher/Services/SelfUpdater.cs +++ b/sources/launcher/Stride.Launcher/Services/SelfUpdater.cs @@ -1,74 +1,136 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; + using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Http; using System.Reflection; using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; +using Avalonia; +using Avalonia.Controls; using Stride.Core; using Stride.Core.Extensions; using Stride.Core.Packages; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; -using Stride.LauncherApp.Views; -using MessageBoxButton = Stride.Core.Presentation.Services.MessageBoxButton; -using MessageBoxImage = Stride.Core.Presentation.Services.MessageBoxImage; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.Services; -namespace Stride.LauncherApp.Services +public static class SelfUpdater { - public static class SelfUpdater - { - public static readonly string Version; - private static readonly HttpClient httpClient = new(); + public static readonly string? Version; - private static SelfUpdateWindow selfUpdateWindow; + private static readonly HttpClient httpClient = new(); + private static SelfUpdateWindow? selfUpdateWindow; - static SelfUpdater() + static SelfUpdater() + { + var assembly = Assembly.GetEntryAssembly(); + var assemblyInformationalVersion = assembly?.GetCustomAttribute<AssemblyInformationalVersionAttribute>(); + Version = assemblyInformationalVersion?.InformationalVersion; + } + + public static void RestartApplication() + { + var args = Environment.GetCommandLineArgs().ToList(); + args.Add("/UpdateTargets"); + if (Program.GetExecutablePath() is string exeLocation) { - var assembly = Assembly.GetEntryAssembly(); - var assemblyInformationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>(); - Version = assemblyInformationalVersion.InformationalVersion; + + var startInfo = new ProcessStartInfo(exeLocation) + { + Arguments = string.Join(" ", args.Skip(1)), + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = true + }; + // Release the mutex before starting the new process + Launcher.Mutex?.Dispose(); + Process.Start(startInfo); } + Environment.Exit(0); + } + + internal static Task SelfUpdate(IViewModelServiceProvider services, NugetStore store) + { + return Task.Run(async () => + { + var dispatcher = services.Get<IDispatcherService>(); + try + { + await UpdateLauncherFiles(dispatcher, services.Get<IDialogService>(), store, CancellationToken.None); + } + catch (Exception) + { + await dispatcher.InvokeAsync(() => selfUpdateWindow?.ForceClose()); + throw; + } + }); + } - internal static Task SelfUpdate(IViewModelServiceProvider services, NugetStore store) + private static async Task DownloadAndInstallNewVersion(IDispatcherService dispatcher, IDialogService dialogService, string strideInstallerUrl) + { + try { - return Task.Run(async () => + // Display progress window + await dispatcher.InvokeAsync(() => { - var dispatcher = services.Get<IDispatcherService>(); - try + selfUpdateWindow = new(); + selfUpdateWindow.LockWindow(); + if (Application.Current is App { MainWindow: Window window }) { - await UpdateLauncherFiles(dispatcher, services.Get<IDialogService>(), store, CancellationToken.None); - } - catch (Exception) - { - dispatcher.Invoke(() => selfUpdateWindow?.ForceClose()); - throw; + _ = selfUpdateWindow.ShowDialog(window); // we don't await on purpose here } + selfUpdateWindow.Show(); }); - } - private static async Task UpdateLauncherFiles(IDispatcherService dispatcher, IDialogService dialogService, NugetStore store, CancellationToken cancellationToken) + var strideInstaller = Path.Combine(Path.GetTempPath(), $"StrideSetup-{Guid.NewGuid()}.exe"); + using (var response = await httpClient.GetAsync(strideInstallerUrl)) + { + response.EnsureSuccessStatusCode(); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + await using var fileStream = File.Create(strideInstaller); + await responseStream.CopyToAsync(fileStream); + } + + var startInfo = new ProcessStartInfo(strideInstaller) + { + UseShellExecute = true + }; + // Release the mutex before starting the new process + Launcher.Mutex?.Dispose(); + Process.Start(startInfo); + Environment.Exit(0); + } + catch (Exception e) { - var version = new PackageVersion(Version); - var productAttribute = (typeof(SelfUpdater).Assembly).GetCustomAttribute<AssemblyProductAttribute>(); - var packageId = productAttribute.Product; - var packages = (await store.GetUpdates(new PackageName(packageId, version), true, true, cancellationToken)).OrderBy(x => x.Version); + await dispatcher.InvokeAsync(() => + { + selfUpdateWindow?.ForceClose(); + }); + await dialogService.MessageBoxAsync(string.Format(Strings.NewVersionDownloadError, e.Message), MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private static async Task UpdateLauncherFiles(IDispatcherService dispatcher, IDialogService dialogService, NugetStore store, CancellationToken cancellationToken) + { + + var version = new PackageVersion(Version); + var productAttribute = (typeof(SelfUpdater).Assembly).GetCustomAttribute<AssemblyProductAttribute>(); + var packageId = productAttribute!.Product; + var packages = (await store.GetUpdates(new(packageId, version), true, true, cancellationToken)).OrderBy(x => x.Version); + + // Force-reinstall downloads a Windows installer (StrideSetup.exe) — skip the probe on non-Windows. + if (OperatingSystem.IsWindows()) + { try { // First, check if there is a package forcing us to download new installer - const string ReinstallUrlPattern = @"force-reinstall:\s*(\S+)\s*(\S+)"; - var reinstallPackage = packages.LastOrDefault(x => x.Version > version && Regex.IsMatch(x.Description, ReinstallUrlPattern)); - if (reinstallPackage != null) + const string reinstallUrlPattern = @"force-reinstall:\s*(\S+)\s*(\S+)"; + var reinstallPackage = packages.LastOrDefault(x => x.Version > version && Regex.IsMatch(x.Description, reinstallUrlPattern)); + if (reinstallPackage is not null) { - var regexMatch = Regex.Match(reinstallPackage.Description, ReinstallUrlPattern); + var regexMatch = Regex.Match(reinstallPackage.Description, reinstallUrlPattern); var minimumVersion = PackageVersion.Parse(regexMatch.Groups[1].Value); if (version < minimumVersion) { @@ -82,202 +144,140 @@ private static async Task UpdateLauncherFiles(IDispatcherService dispatcher, IDi { await dialogService.MessageBoxAsync(string.Format(Strings.NewVersionDownloadError, e.Message), MessageBoxButton.OK, MessageBoxImage.Error); } + } - // If there is a mandatory intermediate upgrade, take it, otherwise update straight to latest version - var package = (packages.FirstOrDefault(x => x.Version > version && x.Version.SpecialVersion == "req") ?? packages.LastOrDefault()); + // If there is a mandatory intermediate upgrade, take it, otherwise update straight to latest version + var package = (packages.FirstOrDefault(x => x.Version > version && x.Version.SpecialVersion == "req") ?? packages.LastOrDefault()); - // Check to see if an update is needed - if (package == null || version >= new PackageVersion(package.Version.Version, package.Version.SpecialVersion)) + // Check to see if an update is needed + if (package is null || version >= new PackageVersion(package.Version.Version, package.Version.SpecialVersion)) + { + return; + } + + // Display progress window + await dispatcher.InvokeAsync(() => + { + selfUpdateWindow = new(); + selfUpdateWindow.LockWindow(); + if (Application.Current is App { MainWindow: Window window }) { - return; + _ = selfUpdateWindow.ShowDialog(window); // we don't await on purpose here } - var windowCreated = new TaskCompletionSource<SelfUpdateWindow>(); - var mainWindow = dispatcher.Invoke(() => Application.Current.MainWindow as LauncherWindow); - if (mainWindow == null) - throw new ApplicationException("Update requested without a Launcher Window. Cannot continue!"); - - dispatcher.InvokeAsync(() => + else { - selfUpdateWindow = new SelfUpdateWindow { Owner = mainWindow }; - windowCreated.SetResult(selfUpdateWindow); - selfUpdateWindow.ShowDialog(); - }).Forget(); - - var movedFiles = new List<string>(); + throw new ApplicationException("Update requested without a Launcher Window. Cannot continue!"); + } + }, cancellationToken); - // Download package - var installedPackage = await store.InstallPackage(package.Id, package.Version, package.TargetFrameworks, null); + var movedFiles = new List<string>(); - // Copy files from tools\ to the current directory - var inputFiles = installedPackage.GetFiles(); + // Download package + var installedPackage = await store.InstallPackage(package.Id, package.Version, package.TargetFrameworks, null); - var window = windowCreated.Task.Result; - dispatcher.Invoke(window.LockWindow); + // Copy files from tools\ to the current directory + var inputFiles = installedPackage.GetFiles(); - // TODO: We should get list of previous files from nuspec (store it as a resource and open it with NuGet API maybe?) - // TODO: For now, we deal only with the App.config file since we won't be able to fix it afterward. - var exeLocation = Launcher.GetExecutablePath(); - var exeDirectory = Path.GetDirectoryName(exeLocation); - const string directoryRoot = "tools/"; // Important!: this is matching where files are store in the nuspec - try + // TODO: We should get list of previous files from nuspec (store it as a resource and open it with NuGet API maybe?) + // TODO: For now, we deal only with the App.config file since we won't be able to fix it afterward. + var exeLocation = Program.GetExecutablePath(); + var exeDirectory = Path.GetDirectoryName(exeLocation)!; + const string directoryRoot = "tools/"; // Important!: this is matching where files are store in the nuspec + try + { + if (File.Exists(exeLocation)) { - if (File.Exists(exeLocation)) - { - Move(exeLocation, exeLocation + ".old"); - movedFiles.Add(exeLocation); - } - var configLocation = exeLocation + ".config"; - if (File.Exists(configLocation)) - { - Move(configLocation, configLocation + ".old"); - movedFiles.Add(configLocation); - } - foreach (var file in inputFiles.Where(file => file.Path.StartsWith(directoryRoot) && !file.Path.EndsWith("/"))) - { - var fileName = Path.Combine(exeDirectory, file.Path.Substring(directoryRoot.Length)); - - // Move previous files to .old - if (File.Exists(fileName)) - { - Move(fileName, fileName + ".old"); - movedFiles.Add(fileName); - } - - // Update the file - UpdateFile(fileName, file); - } + Move(exeLocation, exeLocation + ".old"); + movedFiles.Add(exeLocation); } - catch (Exception) + var configLocation = exeLocation + ".config"; + if (File.Exists(configLocation)) { - // Revert all olds files if a file didn't work well - foreach (var oldFile in movedFiles) - { - Move(oldFile + ".old", oldFile); - } - throw; + Move(configLocation, configLocation + ".old"); + movedFiles.Add(configLocation); } - - - // Remove .old files - foreach (var oldFile in movedFiles) + foreach (var file in inputFiles.Where(file => file.Path.StartsWith(directoryRoot) && !file.Path.EndsWith("/"))) { - try - { - var renamedPath = oldFile + ".old"; + var fileName = Path.Combine(exeDirectory, file.Path.Substring(directoryRoot.Length)); - if (File.Exists(renamedPath)) - { - File.Delete(renamedPath); - } - } - catch (Exception) + // Move previous files to .old + if (File.Exists(fileName)) { - // All the files have been replaced, we let it go even if we cannot remove all the old files. + Move(fileName, fileName + ".old"); + movedFiles.Add(fileName); } - } - - // Clean cache from files obtain via package.GetFiles above. - store.PurgeCache(); - dispatcher.Invoke(RestartApplication); + // Update the file + UpdateFile(fileName, file); + } } - - private static void Move(string oldPath, string newPath) + catch (Exception) { - EnsureDirectory(newPath); - try - { - if (File.Exists(newPath)) - { - File.Delete(newPath); - } - } - catch (FileNotFoundException) + // Revert all olds files if a file didn't work well + foreach (var oldFile in movedFiles) { - + Move(oldFile + ".old", oldFile); } - - File.Move(oldPath, newPath); + throw; } - internal static async Task DownloadAndInstallNewVersion(IDispatcherService dispatcher, IDialogService dialogService, string strideInstallerUrl) + // Remove .old files + foreach (var oldFile in movedFiles) { try { - // Diplay progress window - var mainWindow = dispatcher.Invoke(() => Application.Current.MainWindow as LauncherWindow); - dispatcher.InvokeAsync(() => - { - selfUpdateWindow = new SelfUpdateWindow { Owner = mainWindow }; - selfUpdateWindow.LockWindow(); - selfUpdateWindow.ShowDialog(); - }).Forget(); - + var renamedPath = oldFile + ".old"; - var strideInstaller = Path.Combine(Path.GetTempPath(), $"StrideSetup-{Guid.NewGuid()}.exe"); - using (var response = await httpClient.GetAsync(strideInstallerUrl)) + if (File.Exists(renamedPath)) { - response.EnsureSuccessStatusCode(); - - await using var responseStream = await response.Content.ReadAsStreamAsync(); - await using var fileStream = File.Create(strideInstaller); - responseStream.CopyTo(fileStream); + File.Delete(renamedPath); } - - var startInfo = new ProcessStartInfo(strideInstaller) - { - UseShellExecute = true - }; - // Release the mutex before starting the new process - Launcher.Mutex.Dispose(); - - Process.Start(startInfo); - - Environment.Exit(0); } - catch (Exception e) + catch (Exception) { - await dispatcher.InvokeAsync(() => - { - selfUpdateWindow?.ForceClose(); - }); - - await dialogService.MessageBoxAsync(string.Format(Strings.NewVersionDownloadError, e.Message), MessageBoxButton.OK, MessageBoxImage.Error); + // All the files have been replaced, we let it go even if we cannot remove all the old files. } } - private static void EnsureDirectory(string filePath) + // Clean cache from files obtain via package.GetFiles above. + store.PurgeCache(); + // Restart + dispatcher.InvokeAsync(RestartApplication, cancellationToken).Forget(); + return; + + static void EnsureDirectory(string filePath) { // Create dest directory if it exists var directory = Path.GetDirectoryName(filePath); - if (directory != null && !Directory.Exists(directory)) + if (directory is not null && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } } - private static void UpdateFile(string newFilePath, PackageFile file) + static void Move(string oldPath, string newPath) { - EnsureDirectory(newFilePath); - using Stream fromStream = file.GetStream(), toStream = File.Create(newFilePath); - fromStream.CopyTo(toStream); + EnsureDirectory(newPath); + try + { + if (File.Exists(newPath)) + { + File.Delete(newPath); + } + } + catch (FileNotFoundException) + { + } + + File.Move(oldPath, newPath); } - public static void RestartApplication() + static void UpdateFile(string newFilePath, PackageFile file) { - var args = Environment.GetCommandLineArgs().ToList(); - args.Add("/UpdateTargets"); - var exeLocation = Launcher.GetExecutablePath(); - var startInfo = new ProcessStartInfo(exeLocation) - { - Arguments = string.Join(" ", args.Skip(1)), - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = true - }; - // Release the mutex before starting the new process - Launcher.Mutex.Dispose(); - Process.Start(startInfo); - Environment.Exit(0); + EnsureDirectory(newFilePath); + using var fromStream = file.GetStream(); + using var toStream = File.Create(newFilePath); + fromStream.CopyTo(toStream); } } } diff --git a/sources/launcher/Stride.Launcher/Services/UninstallHelper.cs b/sources/launcher/Stride.Launcher/Services/UninstallHelper.cs index 8fa6ef1808..9aaf0f2883 100644 --- a/sources/launcher/Stride.Launcher/Services/UninstallHelper.cs +++ b/sources/launcher/Stride.Launcher/Services/UninstallHelper.cs @@ -1,153 +1,149 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; + using System.Diagnostics; -using System.Linq; using Stride.Core.Extensions; using Stride.Core.Packages; +using Stride.Core.Presentation.Avalonia.Windows; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -namespace Stride.LauncherApp.Services +namespace Stride.Launcher.Services; + +internal class UninstallHelper : IDisposable { - internal class UninstallHelper : IDisposable - { - private readonly IViewModelServiceProvider serviceProvider; - private readonly NugetStore store; + private readonly NugetStore store; - internal UninstallHelper(IViewModelServiceProvider serviceProvider, NugetStore store) - { - this.serviceProvider = serviceProvider; - this.store = store; - store.NugetPackageUninstalling += PackageUninstalling; - } + internal UninstallHelper(IViewModelServiceProvider serviceProvider, NugetStore store) + { + this.store = store; + store.NugetPackageUninstalling += PackageUninstalling; + } - public void Dispose() - { - store.NugetPackageUninstalling -= PackageUninstalling; - } + public void Dispose() + { + store.NugetPackageUninstalling -= PackageUninstalling; + } - /// <summary> - /// Closes all processes that were started from the given directory or one of its subdirectory. If the process has a window, - /// this method will spawn a dialog box to ask the user to terminate the process himself. - /// </summary> - /// <param name="showMessage">An function that will display a message box with the given text and OK/Cancel buttons, and returns <c>True</c> if the user pressed OK or <c>False</c> if he pressed Cancel.</param> - /// <param name="uninstallingProgramName">The name of the program being uninstalled, used for displaying a dialog message.</param> - /// <param name="path">The path in which processes to terminate are located.</param> - /// <returns><c>True</c> if all the processes were terminated, <c>False</c> if the user cancelled the operation.</returns> - /// <remarks>There is no guarantee that all processes will be killed at the end. An error might occurs when trying to close a process.</remarks> - public static bool CloseProcessesInPath(Func<string, bool> showMessage, string uninstallingProgramName, string path) + /// <summary> + /// Closes all processes that were started from the given directory or one of its subdirectory. If the process has a window, + /// this method will spawn a dialog box to ask the user to terminate the process himself. + /// </summary> + /// <param name="showMessageAsync">An function that will display a message box with the given text and OK/Cancel buttons, and returns <c>True</c> if the user pressed OK or <c>False</c> if he pressed Cancel.</param> + /// <param name="uninstallingProgramName">The name of the program being uninstalled, used for displaying a dialog message.</param> + /// <param name="path">The path in which processes to terminate are located.</param> + /// <returns><c>True</c> if all the processes were terminated, <c>False</c> if the user cancelled the operation.</returns> + /// <remarks>There is no guarantee that all processes will be killed at the end. An error might occurs when trying to close a process.</remarks> + public static async Task<bool> CloseProcessesInPathAsync(Func<string, Task<bool>> showMessageAsync, string uninstallingProgramName, string path) + { + // Check processes + var processesWithWindow = new List<Tuple<string, Process>>(); + List<Process> processes; + do { - // Check processes - var processesWithWindow = new List<Tuple<string, Process>>(); - List<Process> processes; - do - { - processes = CollectPackageProcesses(path); - - // Make sure all process with main window are closed - processesWithWindow.Clear(); - foreach (var process in processes) - { - try - { - if (process.MainWindowHandle != IntPtr.Zero) - { - processesWithWindow.Add(Tuple.Create(process.MainModule.ModuleName, process)); - } - } - catch (Exception exception) - { - exception.Ignore(); - } - } - - // There is still process with main window, inform user so that he can properly close them - if (processesWithWindow.Count > 0) - { - var nl = Environment.NewLine; - // Display error to user and block until he presses try again - var runningProcesses = string.Join(nl, processesWithWindow.GroupBy(x => x.Item1).Select(x => $" - {x.Key} ({x.Count()} instance(s))")); - var message = $"Can't uninstall {uninstallingProgramName} because processes are still running:{nl}{runningProcesses}{nl}{nl}Please close them and press OK to try again, or Cancel to stop."; - var confirmResult = showMessage(message); - - if (!confirmResult) - { - // User pressed Cancel, no need to uninstall - return false; - } - } - } while (processesWithWindow.Count > 0); + processes = CollectPackageProcesses(path); - // Kill all other processes (there should be no processes with main window left, so probably services/console apps) + // Make sure all process with main window are closed + processesWithWindow.Clear(); foreach (var process in processes) { try { - try - { - process.StandardInput.Close(); - } - catch + if (process.MainWindowHandle != IntPtr.Zero) { - process.Kill(); + processesWithWindow.Add(Tuple.Create(process.MainModule!.ModuleName, process)); } } catch (Exception exception) { - // Ignore weird errors (process gone, etc...) exception.Ignore(); } } - return true; - } + // There is still process with main window, inform user so that he can properly close them + if (processesWithWindow.Count > 0) + { + var nl = Environment.NewLine; + // Display error to user and block until he presses try again + var runningProcesses = string.Join(nl, processesWithWindow.GroupBy(x => x.Item1).Select(x => $" - {x.Key} ({x.Count()} instance(s))")); + var message = $"Can't uninstall {uninstallingProgramName} because processes are still running:{nl}{runningProcesses}{nl}{nl}Please close them and press OK to try again, or Cancel to stop."; + var confirmResult = await showMessageAsync(message); - private static bool IsPathInside(string folder, string path) - { - // Can probably be improved (not sure how stable and unique path could be?) - return (path.IndexOf(folder, StringComparison.OrdinalIgnoreCase) != -1); - } + if (!confirmResult) + { + // User pressed Cancel, no need to uninstall + return false; + } + } + } while (processesWithWindow.Count > 0); - private static List<Process> CollectPackageProcesses(string installPath) + // Kill all other processes (there should be no processes with main window left, so probably services/console apps) + foreach (var process in processes) { - var result = new List<Process>(); - foreach (var process in Process.GetProcesses()) + try { try { - var filename = process.MainModule.FileName; - - // Check if filename is inside install path - if (!IsPathInside(installPath, filename)) - continue; - - // Discard ourselves - if (process.Id == Environment.ProcessId) - continue; - - result.Add(process); + process.StandardInput.Close(); } - catch (Exception exception) + catch { - // Many errors can happen when accessing process main module (permission, process killed, etc...) - exception.Ignore(); + process.Kill(); } } - - return result; + catch (Exception exception) + { + // Ignore weird errors (process gone, etc...) + exception.Ignore(); + } } - private bool DisplayMessage(string message) - { - var result = serviceProvider.Get<IDialogService2>().BlockingMessageBox(message, MessageBoxButton.OKCancel); - return result != MessageBoxResult.Cancel; - } + return true; + } + + private static bool IsPathInside(string folder, string path) + { + // Can probably be improved (not sure how stable and unique path could be?) + return (path.IndexOf(folder, StringComparison.OrdinalIgnoreCase) != -1); + } - private void PackageUninstalling(object sender, PackageOperationEventArgs e) + private static List<Process> CollectPackageProcesses(string installPath) + { + var result = new List<Process>(); + foreach (var process in Process.GetProcesses()) { - CloseProcessesInPath(DisplayMessage, e.Id, e.InstallPath); + try + { + var filename = process.MainModule!.FileName; + + // Check if filename is inside install path + if (!IsPathInside(installPath, filename)) + continue; + + // Discard ourselves + if (process.Id == Environment.ProcessId) + continue; + + result.Add(process); + } + catch (Exception exception) + { + // Many errors can happen when accessing process main module (permission, process killed, etc...) + exception.Ignore(); + } } + + return result; + } + + private static async Task<bool> DisplayMessageAsync(string message) + { + var result = await MessageBox.ShowAsync(Launcher.ApplicationName, message, IDialogService.GetButtons(MessageBoxButton.OKCancel)); + return result != (int)MessageBoxResult.Cancel; + } + + private static async void PackageUninstalling(object? sender, PackageOperationEventArgs e) + { + await CloseProcessesInPathAsync(DisplayMessageAsync, e.Id, e.InstallPath); } } diff --git a/sources/launcher/Stride.Launcher/Stride.Launcher.csproj b/sources/launcher/Stride.Launcher/Stride.Launcher.csproj index ac8569623f..038a967c6c 100644 --- a/sources/launcher/Stride.Launcher/Stride.Launcher.csproj +++ b/sources/launcher/Stride.Launcher/Stride.Launcher.csproj @@ -1,134 +1,85 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> - <TargetFramework>net10.0-windows</TargetFramework> - <RuntimeIdentifier>win-x64</RuntimeIdentifier> + <OutputType Condition="'$(Configuration)' == 'Debug'">Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + <DebugType>embedded</DebugType> + <RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> - <UseWPF>true</UseWPF> - <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally> - <ImplicitUsings>enable</ImplicitUsings> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> - <PlatformTarget>AnyCPU</PlatformTarget> - <OutputPath>bin\Debug\</OutputPath> - <UseVSHostingProcess>false</UseVSHostingProcess> - <DefineConstants>TRACE;STRIDE_LAUNCHER</DefineConstants> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> - <PlatformTarget>AnyCPU</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE;STRIDE_LAUNCHER</DefineConstants> - </PropertyGroup> - <PropertyGroup> - <ApplicationIcon>Resources\Launcher.ico</ApplicationIcon> - <RootNamespace>Stride.LauncherApp</RootNamespace> + <BuiltInComInteropSupport>true</BuiltInComInteropSupport> + <ApplicationIcon>Assets\Launcher.ico</ApplicationIcon> <ApplicationManifest>app.manifest</ApplicationManifest> - <StartupObject>Stride.LauncherApp.Program</StartupObject> + <StartupObject>Stride.Launcher.Program</StartupObject> + <DefineConstants>$(DefineConstants);STRIDE_LAUNCHER</DefineConstants> <!-- Get version directly from nuspec file, so that there's only one place to bump --> <_StrideLauncherNuSpecLines>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)Stride.Launcher.nuspec'))</_StrideLauncherNuSpecLines> <Version>$([System.Text.RegularExpressions.Regex]::Match($(_StrideLauncherNuSpecLines), `<version>(.*)</version>`).Groups[1].Value)</Version> + <ImplicitUsings>enable</ImplicitUsings> + <LangVersion>latest</LangVersion> + <Nullable>enable</Nullable> + <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> </PropertyGroup> + <ItemGroup> - <Using Include="System.IO" /> - </ItemGroup> - <ItemGroup> - <None Remove="Resources\list-26.png" /> + <PackageReference Include="Avalonia.Desktop" /> + <PackageReference Include="Avalonia.Fonts.Inter" /> + <PackageReference Include="Avalonia.Themes.Fluent" /> + <!--Condition below is needed to remove AvaloniaUI.DiagnosticsSupport package from build output in Release configuration.--> + <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" /> + <PackageReference Include="MarkView.Avalonia.Mermaid" /> + <PackageReference Include="MarkView.Avalonia.Svg" /> + <PackageReference Include="MarkView.Avalonia.SyntaxHighlighting" /> </ItemGroup> + <ItemGroup> - <TrimmerRootAssembly Include="System.Xml.Linq" /> + <ProjectReference Include="..\..\assets\Stride.Core.Packages\Stride.Core.Packages.csproj" /> + <ProjectReference Include="..\..\presentation\Stride.Core.Presentation.Avalonia\Stride.Core.Presentation.Avalonia.csproj" /> </ItemGroup> + <ItemGroup> - <PackageReference Include="Stride.Metrics" Version="1.0.3" /> + <None Include="..\..\Directory.Packages.props" Link="Build\Directory.Packages.props" /> </ItemGroup> + <ItemGroup> - <Compile Include="..\..\assets\Stride.Core.Assets\PackageSessionHelper.Solution.cs"> - <Link>Packages\PackageSessionHelper.Solution.cs</Link> - </Compile> - <Compile Include="..\..\assets\Stride.Core.Assets\Package.Constants.cs"> - <Link>Packages\Package.Constants.cs</Link> - </Compile> - <Compile Include="..\..\editor\Stride.Core.Assets.Editor\EditorPath.cs"> - <Link>Editor\EditorPath.cs</Link> - </Compile> + <Compile Remove="..\..\shared\SharedAssemblyInfo.cs" /> + <Compile Include="..\..\assets\Stride.Core.Assets\PackageSessionHelper.Solution.cs" Link="Packages\PackageSessionHelper.Solution.cs" /> + <Compile Include="..\..\editor\Stride.Core.Assets.Editor\EditorPath.cs" Link="Editor\EditorPath.cs" /> </ItemGroup> + <ItemGroup> - <EmbeddedResource Update="Announcements\Release30.md" /> - <Resource Include="Resources\EditorIcon.png" /> - <Resource Include="Resources\list-26.png" /> - <Resource Include="Resources\update.png" /> - <Resource Include="Resources\roadmap.png" /> - <Resource Include="Resources\survey.png" /> - <Resource Include="Resources\upgrade-16.png" /> - <Resource Include="Resources\note-26-dark.png" /> - <Resource Include="Resources\recent-projects.png" /> - <Resource Include="Resources\switch-version.png" /> - <Resource Include="Resources\visual-studio.png" /> - <Resource Include="Resources\chat-16.png" /> - <Resource Include="Resources\discord.png" /> - <Resource Include="Resources\showcase.png" /> - <Resource Include="Resources\Robot.jpg" /> - <Resource Include="Resources\twitch_24.png" /> - <Resource Include="Resources\facebook_24.png" /> - <Resource Include="Resources\reddit_24.png" /> - <Resource Include="Resources\xtwitter_24.png" /> - <Resource Include="Resources\opencollective_24.png" /> - <Resource Include="Resources\github.png" /> - <Resource Include="Resources\issues.png" /> - <Resource Include="Resources\Launcher.ico" /> - <Resource Include="Resources\delete-26-dark.png" /> - <Resource Include="Resources\download-26-dark.png" /> + <AvaloniaResource Include="Assets\**" /> </ItemGroup> + <ItemGroup> - <EmbeddedResource Update="Resources\Strings.ja-JP.resx" /> - <EmbeddedResource Update="Resources\Strings.resx"> - <Generator>PublicResXFileCodeGenerator</Generator> - <LastGenOutput>Strings.Designer.cs</LastGenOutput> + <AvaloniaResource Remove="Assets\Localization\**" /> + <EmbeddedResource Update="Assets\Localization\Strings.ja-JP.resx" /> + <EmbeddedResource Update="Assets\Localization\Strings.resx"> <SubType>Designer</SubType> - </EmbeddedResource> - <EmbeddedResource Update="Resources\Urls.ja-JP.resx" /> - <EmbeddedResource Update="Resources\Urls.resx"> + <LastGenOutput>Strings.Designer.cs</LastGenOutput> <Generator>PublicResXFileCodeGenerator</Generator> + </EmbeddedResource> + <EmbeddedResource Update="Assets\Localization\Urls.ja-JP.resx" /> + <EmbeddedResource Update="Assets\Localization\Urls.resx"> + <SubType>Designer</SubType> <LastGenOutput>Urls.Designer.cs</LastGenOutput> + <Generator>PublicResXFileCodeGenerator</Generator> </EmbeddedResource> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\..\assets\Stride.Core.Packages\Stride.Core.Packages.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.Design\Stride.Core.Design.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.IO\Stride.Core.IO.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.Mathematics\Stride.Core.Mathematics.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.Reflection\Stride.Core.Reflection.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.Serialization\Stride.Core.Serialization.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core.Yaml\Stride.Core.Yaml.csproj" /> - <ProjectReference Include="..\..\core\Stride.Core\Stride.Core.csproj" /> - <ProjectReference Include="..\..\editor\Stride.Editor.CrashReport\Stride.Editor.CrashReport.csproj" /> - <ProjectReference Include="..\..\presentation\Stride.Core.Presentation\Stride.Core.Presentation.csproj" /> - <ProjectReference Include="..\..\presentation\Stride.Core.Presentation.Dialogs\Stride.Core.Presentation.Dialogs.csproj" /> - </ItemGroup> - <ItemGroup> - <Service Include="{508349B6-6B84-4DF5-91F0-309BEEBAD82D}" /> - </ItemGroup> - <ItemGroup> - <TrimmerRootAssembly Include="System.Runtime" /> - <TrimmerRootAssembly Include="System.Diagnostics.Debug" /> - <TrimmerRootAssembly Include="System.Runtime.Extensions" /> - </ItemGroup> - <ItemGroup> - <Compile Update="Resources\Strings.Designer.cs"> + <Compile Update="Assets\Localization\Strings.Designer.cs"> <DesignTime>True</DesignTime> <AutoGen>True</AutoGen> <DependentUpon>Strings.resx</DependentUpon> </Compile> - <Compile Update="Resources\Urls.Designer.cs"> + <Compile Update="Assets\Localization\Urls.Designer.cs"> <DesignTime>True</DesignTime> <AutoGen>True</AutoGen> <DependentUpon>Urls.resx</DependentUpon> </Compile> </ItemGroup> - <Import Project="..\..\editor\Stride.PrivacyPolicy\Stride.PrivacyPolicy.projitems" Label="Shared" Condition="Exists('..\..\editor\Stride.PrivacyPolicy\Stride.PrivacyPolicy.projitems')" /> + <Import Project="..\..\editor\Stride.Core.MostRecentlyUsedFiles\Stride.Core.MostRecentlyUsedFiles.projitems" Label="Shared" /> - <Import Project="..\..\assets\Stride.Core.Assets.Yaml\Stride.Core.Assets.Yaml.projitems" Label="Shared" /> + + <ItemGroup> + <InternalsVisibleTo Include="Stride.Launcher.Tests" /> + </ItemGroup> </Project> diff --git a/sources/launcher/Stride.Launcher/Stride.Launcher.nuspec b/sources/launcher/Stride.Launcher/Stride.Launcher.nuspec index bde13d9441..92f2275c4f 100644 --- a/sources/launcher/Stride.Launcher/Stride.Launcher.nuspec +++ b/sources/launcher/Stride.Launcher/Stride.Launcher.nuspec @@ -2,7 +2,7 @@ <package> <metadata> <id>Stride.Launcher</id> - <version>5.0.6</version> + <version>6.0.1</version> <authors>Stride</authors> <owners>Stride</owners> <license type="expression">MIT</license> diff --git a/sources/launcher/Stride.Launcher/ViewModels/AnnouncementViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/AnnouncementViewModel.cs index 0f439fd6d2..25f07198a6 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/AnnouncementViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/AnnouncementViewModel.cs @@ -1,79 +1,70 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.IO; -using System.Linq; + using System.Reflection; -using Stride.Core.Extensions; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.ViewModels; +using Stride.Launcher.Services; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed class AnnouncementViewModel : DispatcherViewModel { - internal class AnnouncementViewModel : DispatcherViewModel - { - private readonly LauncherViewModel launcher; - private readonly string announcementName; - private bool validated = true; - private bool dontShowAgain; + private readonly string announcementName; + private readonly ILauncherSettingsService _settings; + private bool dontShowAgain; + private bool validated = true; - public AnnouncementViewModel(LauncherViewModel launcher, string announcementName) - : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) + public AnnouncementViewModel(IViewModelServiceProvider serviceProvider, string announcementName) + : base(serviceProvider) + { + _settings = serviceProvider.Get<ILauncherSettingsService>(); + this.announcementName = announcementName; + if (!_settings.IsTaskCompleted(TaskName)) { - this.launcher = launcher; - this.announcementName = announcementName; - if (!LauncherViewModel.HasDoneTask(TaskName)) - { - MarkdownAnnouncement = Initialize(announcementName); - } - // We want to explicitely trigger the property change notification for the view storyboard - Dispatcher.InvokeAsync(() => Validated = false); - CloseAnnouncementCommand = new AnonymousCommand(ServiceProvider, CloseAnnouncement); + MarkdownAnnouncement = Initialize(announcementName); } - private void CloseAnnouncement() - { - Validated = true; - if (DontShowAgain) - { - LauncherViewModel.SaveTaskAsDone(TaskName); - } - } + CloseAnnouncementCommand = new AnonymousCommand(ServiceProvider, CloseAnnouncement); + // We want to explicitly trigger the property change notification for the view storyboard + Validated = false; + } - public string MarkdownAnnouncement { get; } + public bool DontShowAgain { get { return dontShowAgain; } set { SetValue(ref dontShowAgain, value); } } - public bool Validated { get { return validated; } set { SetValue(ref validated, value); } } + public string? MarkdownAnnouncement { get; } - public bool DontShowAgain { get { return dontShowAgain; } set { SetValue(ref dontShowAgain, value); } } + public bool Validated { get { return validated; } set { SetValue(ref validated, value); } } - public ICommandBase CloseAnnouncementCommand { get; } + private string TaskName => "Announcement" + announcementName; - private string TaskName => GetTaskName(announcementName); + public ICommandBase CloseAnnouncementCommand { get; } - public static string GetTaskName(string announcementName) + private void CloseAnnouncement() + { + Validated = true; + if (DontShowAgain) { - return "Announcement" + announcementName; + _settings.MarkTaskCompleted(TaskName); } + } - private static string Initialize(string announcementName) + private static string? Initialize(string announcementName) + { + try { - try - { - var executingAssembly = Assembly.GetExecutingAssembly(); - var path = Assembly.GetExecutingAssembly().GetManifestResourceNames().Single(x => x.EndsWith(announcementName + ".md")); - using (var stream = executingAssembly.GetManifestResourceStream(path)) - { - if (stream == null) - return null; - - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } - } - catch (Exception) - { + var executingAssembly = Assembly.GetExecutingAssembly(); + var path = Assembly.GetExecutingAssembly().GetManifestResourceNames().Single(x => x.EndsWith(announcementName + ".md")); + using var stream = executingAssembly.GetManifestResourceStream(path); + if (stream is null) return null; - } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + catch (Exception) + { + return null; } } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/DocumentationPageViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/DocumentationPageViewModel.cs index ab14c0b07f..d07c0b54df 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/DocumentationPageViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/DocumentationPageViewModel.cs @@ -1,132 +1,131 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; + using System.Diagnostics; -using System.Net.Http; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed partial class DocumentationPageViewModel : DispatcherViewModel { - internal class DocumentationPageViewModel : DispatcherViewModel + private static readonly Regex ParsingRegex = GetParsingRegex(); + private static readonly HttpClient httpClient = new(); + private const string DocPageScheme = "page:"; + private const string PageUrlFormatString = "{0}{1}"; + + public DocumentationPageViewModel(IViewModelServiceProvider serviceProvider, string version) + : base(serviceProvider) { - private static readonly Regex ParsingRegex = new Regex(@"\{([^\{\}]+)\}\{([^\{\}]+)\}\{([^\{\}]+)\}"); - private static readonly HttpClient httpClient = new(); - private const string DocPageScheme = "page:"; - private const string PageUrlFormatString = "{0}{1}"; + Version = version; + OpenUrlCommand = new AnonymousTaskCommand(ServiceProvider, OpenUrl); + } - public DocumentationPageViewModel(IViewModelServiceProvider serviceProvider, string version) - : base(serviceProvider) + private async Task OpenUrl() + { + try { - Version = version; - OpenUrlCommand = new AnonymousTaskCommand(ServiceProvider, OpenUrl); + Process.Start(new ProcessStartInfo(Url) { UseShellExecute = true }); } - - private async Task OpenUrl() + catch (Exception) { - try - { - Process.Start(new ProcessStartInfo(Url) { UseShellExecute = true }); - } - catch (Exception) - { - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); - } + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); } + } + + /// <summary> + /// Gets the root url of the documentation that should be opened when the user want to open Stride help. + /// </summary> + public string DocumentationRootUrl => GetDocumentationRootUrl(Version); + + /// <summary> + /// Gets the version related to this documentation page. + /// </summary> + public string Version { get; } + + /// <summary> + /// Gets or sets the title of this documentation page. + /// </summary> + public string? Title { get; set; } - /// <summary> - /// Gets the root url of the documentation that should be opened when the user want to open Stride help. - /// </summary> - public string DocumentationRootUrl => GetDocumentationRootUrl(Version); - - /// <summary> - /// Gets the version related to this documentation page. - /// </summary> - public string Version { get; } - - /// <summary> - /// Gets or sets the title of this documentation page. - /// </summary> - public string Title { get; set; } - - /// <summary> - /// Gets or sets the description of this documentation page. - /// </summary> - public string Description { get; set; } - - /// <summary> - /// Gets or sets the url of this documentation page. - /// </summary> - public string Url { get; set; } - - /// <summary> - /// Gets a command that will open the documentation page in the default web browser. - /// </summary> - public ICommandBase OpenUrlCommand { get; private set; } - - public static async Task<List<DocumentationPageViewModel>> FetchGettingStartedPages(IViewModelServiceProvider serviceProvider, string version) + /// <summary> + /// Gets or sets the description of this documentation page. + /// </summary> + public string? Description { get; set; } + + /// <summary> + /// Gets or sets the url of this documentation page. + /// </summary> + public string? Url { get; set; } + + /// <summary> + /// Gets a command that will open the documentation page in the default web browser. + /// </summary> + public ICommandBase OpenUrlCommand { get; private set; } + + public static async Task<List<DocumentationPageViewModel>> FetchGettingStartedPages(IViewModelServiceProvider serviceProvider, string version) + { + var result = new List<DocumentationPageViewModel>(); + string urlData; + try { - var result = new List<DocumentationPageViewModel>(); - string urlData; - try - { - using var response = await httpClient.GetAsync(string.Format(Urls.GettingStarted, version)); - response.EnsureSuccessStatusCode(); - urlData = await response.Content.ReadAsStringAsync(); + using var response = await httpClient.GetAsync(string.Format(Urls.GettingStarted, version)); + response.EnsureSuccessStatusCode(); + urlData = await response.Content.ReadAsStringAsync(); - if (urlData == null) + if (urlData is null) + { + return result; + } + var urls = urlData.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var url in urls) + { + var match = ParsingRegex.Match(url); + if (!match.Success || match.Groups.Count != 4) { - return result; + continue; } - var urls = urlData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var url in urls) + var link = match.Groups[3].Value; + if (link.StartsWith(DocPageScheme)) { - var match = ParsingRegex.Match(url); - if (!match.Success || match.Groups.Count != 4) - { - continue; - } - var link = match.Groups[3].Value; - if (link.StartsWith(DocPageScheme)) - { - link = GetDocumentationPageUrl(version, link.Substring(DocPageScheme.Length)); - } - var page = new DocumentationPageViewModel(serviceProvider, version) - { - Title = match.Groups[1].Value.Trim(), - Description = match.Groups[2].Value.Trim(), - Url = link.Trim() - }; - result.Add(page); + link = GetDocumentationPageUrl(version, link.Substring(DocPageScheme.Length)); } - return result; - } - catch (Exception) - { - result.Clear(); + var page = new DocumentationPageViewModel(serviceProvider, version) + { + Title = match.Groups[1].Value.Trim(), + Description = match.Groups[2].Value.Trim(), + Url = link.Trim() + }; + result.Add(page); } return result; } - - /// <summary> - /// Compute the url of a documentation page, given the page name. - /// </summary> - /// <param name="version">The version related to this documentation page.</param> - /// <param name="pageName">The name of the page.</param> - /// <returns>The complete url of the documentation page.</returns> - private static string GetDocumentationPageUrl(string version, string pageName) + catch (Exception) { - return string.Format(PageUrlFormatString, GetDocumentationRootUrl(version), pageName); + result.Clear(); } + return result; + } - private static string GetDocumentationRootUrl(string version) - { - return string.Format(Urls.Documentation, version); - } + /// <summary> + /// Compute the url of a documentation page, given the page name. + /// </summary> + /// <param name="version">The version related to this documentation page.</param> + /// <param name="pageName">The name of the page.</param> + /// <returns>The complete url of the documentation page.</returns> + private static string GetDocumentationPageUrl(string version, string pageName) + { + return string.Format(PageUrlFormatString, GetDocumentationRootUrl(version), pageName); } + + private static string GetDocumentationRootUrl(string version) + { + return string.Format(Urls.Documentation, version); + } + + [GeneratedRegex(@"\{([^\{\}]+)\}\{([^\{\}]+)\}\{([^\{\}]+)\}")] + private static partial Regex GetParsingRegex(); } diff --git a/sources/launcher/Stride.Launcher/ViewModels/FrameworkConverter.cs b/sources/launcher/Stride.Launcher/ViewModels/FrameworkConverter.cs index d1afa8bdae..7ca85fac1d 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/FrameworkConverter.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/FrameworkConverter.cs @@ -1,30 +1,25 @@ -using System; +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + using System.Globalization; using NuGet.Frameworks; -using Stride.Core.Annotations; -using Stride.Core.Presentation.ValueConverters; +using Stride.Core.Presentation.Avalonia.Converters; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed class FrameworkConverter : OneWayValueConverter<FrameworkConverter> { - class FrameworkConverter : ValueConverterBase<FrameworkConverter> + public override object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - public override object Convert(object value, [NotNull] Type targetType, object parameter, CultureInfo culture) - { - var frameworkFolder = (string)value; - - var framework = NuGetFramework.ParseFolder(frameworkFolder); - if (framework.Framework == ".NETFramework") - return $".NET {framework.Version.ToString(3)}"; - else if (framework.Framework == ".NETCoreApp") - return $".NET Core {framework.Version.ToString(2)}"; + var frameworkFolder = (string?)value ?? string.Empty; - // fallback - return $"{framework.Framework} {framework.Version.ToString(3)}"; - } + var framework = NuGetFramework.ParseFolder(frameworkFolder); + if (framework.Framework == ".NETFramework") + return $".NET {framework.Version.ToString(3)}"; + else if (framework.Framework == ".NETCoreApp") + return $".NET Core {framework.Version.ToString(2)}"; - public override object ConvertBack(object value, [NotNull] Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } + // fallback + return $"{framework.Framework} {framework.Version.ToString(3)}"; } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/LauncherViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/LauncherViewModel.cs deleted file mode 100644 index f76b9c36f1..0000000000 --- a/sources/launcher/Stride.Launcher/ViewModels/LauncherViewModel.cs +++ /dev/null @@ -1,682 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Win32; -using Stride.Core.CodeEditorSupport.VisualStudio; -using Stride.Core.Extensions; -using Stride.Core.Packages; -using Stride.Core.Presentation.Collections; -using Stride.Core.Presentation.Commands; -using Stride.Core.Presentation.Services; -using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; -using Stride.LauncherApp.Services; -using Stride.Metrics; - -namespace Stride.LauncherApp.ViewModels -{ - /// <summary> - /// This class represents the root view model of the launcher. - /// </summary> - internal class LauncherViewModel : DispatcherViewModel, IPackagesLogger, IDisposable - { - private readonly NugetStore store; - private readonly SortedObservableCollection<StrideVersionViewModel> strideVersions = new SortedObservableCollection<StrideVersionViewModel>(); - private readonly UninstallHelper uninstallHelper; - private readonly object objectLock = new object(); - private ObservableList<NewsPageViewModel> newsPages; - private ReleaseNotesViewModel activeReleaseNotes; - private StrideVersionViewModel activeVersion; - private bool isOffline; - private bool isSynchronizing = true; - private string currentToolTip; - private List<(DateTime Time, MessageLevel Level, string Message)> logMessages = new(); - private bool autoCloseLauncher = LauncherSettings.CloseLauncherAutomatically; - private bool lastActiveVersionRestored; - private AnnouncementViewModel announcement; - private bool isVisible; - private bool showBetaVersions; - - internal LauncherViewModel(IViewModelServiceProvider serviceProvider, NugetStore store) - : base(serviceProvider) - { - DependentProperties.Add("ActiveVersion", new[] { "ActiveDocumentationPages" }); - this.store = store ?? throw new ArgumentNullException(nameof(store)); - store.Logger = this; - - DisplayReleaseAnnouncement(); - - VsixPackage2019 = new VsixVersionViewModel(this, store, store.VsixPackageId, NugetStore.VsixSupportedVsVersion.VS2019); - VsixPackage2022 = new VsixVersionViewModel(this, store, store.VsixPackageId, NugetStore.VsixSupportedVsVersion.VS2022AndNext); - // Commands - InstallLatestVersionCommand = new AnonymousTaskCommand(ServiceProvider, InstallLatestVersion) { IsEnabled = false }; - OpenUrlCommand = new AnonymousTaskCommand<string>(ServiceProvider, OpenUrl); - ReconnectCommand = new AnonymousTaskCommand(ServiceProvider, async () => - { - // We are back online (or so we think) - IsOffline = false; - await FetchOnlineData(); - }); - StartStudioCommand = new AnonymousTaskCommand(ServiceProvider, StartStudio) { IsEnabled = false }; - CheckDeprecatedSourcesCommand = new AnonymousTaskCommand(ServiceProvider, async () => - { - var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null); - if (NugetStore.CheckPackageSource(settings, "Stride")) - { - return; - } - // Add Stride package store (still used for Xenko up to 3.0) - if (await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.AskAddNugetDeprecatedSource, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) - { - NugetStore.UpdatePackageSource(settings, "Stride", "https://packages.stride3d.net/nuget"); - settings.SaveToDisk(); - - SelfUpdater.RestartApplication(); - } - }); - - foreach (var devVersion in LauncherSettings.DeveloperVersions) - { - var version = new StrideDevVersionViewModel(this, store, null, devVersion, false); - strideVersions.Add(version); - } - FetchOnlineData().Forget(); - LoadRecentProjects(); - uninstallHelper = new UninstallHelper(serviceProvider, store); - GameStudioSettings.RecentProjectsUpdated += (sender, e) => Dispatcher.InvokeAsync(LoadRecentProjects).Forget(); - } - - public void Dispose() - { - uninstallHelper.Dispose(); - } - - public static IntPtr WindowHandle { get; set; } - - public IEnumerable<StrideVersionViewModel> StrideVersions => strideVersions; - - public bool ShowBetaVersions { get { return showBetaVersions; } set { SetValue(ref showBetaVersions, value); } } - - public VsixVersionViewModel VsixPackage2019 { get; } - - public VsixVersionViewModel VsixPackage2022 { get; } - - public StrideVersionViewModel ActiveVersion { get { return activeVersion; } set { SetValue(ref activeVersion, value); Dispatcher.InvokeAsync(() => StartStudioCommand.IsEnabled = (value != null) && value.CanStart); } } - - public ObservableList<RecentProjectViewModel> RecentProjects { get; } = new ObservableList<RecentProjectViewModel>(); - - public ObservableList<NewsPageViewModel> NewsPages { get { return newsPages; } private set { SetValue(ref newsPages, value); } } - - public ReleaseNotesViewModel ActiveReleaseNotes { get { return activeReleaseNotes; } set { SetValue(ref activeReleaseNotes, value); } } - - public ObservableList<DocumentationPageViewModel> ActiveDocumentationPages => ActiveVersion.Yield().Concat(StrideVersions).OfType<StrideStoreVersionViewModel>().FirstOrDefault()?.DocumentationPages; - - public AnnouncementViewModel Announcement { get { return announcement; } set { SetValue(ref announcement, value); } } - - public bool IsOffline { get { return isOffline; } set { SetValue(ref isOffline, value); } } - - public bool IsSynchronizing { get { return isSynchronizing; } set { SetValue(ref isSynchronizing, value); } } - - public string CurrentToolTip { get { return currentToolTip; } set { SetValue(ref currentToolTip, value); } } - - public string LogMessages - { - get - { - lock (logMessages) - { - if (logMessages.Count == 0) - return "Empty"; - return string.Join(Environment.NewLine, logMessages.Select(x => $"[{x.Time.ToString("HH:mm:ss")}] {x.Level}: {x.Message}")); - } - } - } - - public bool AutoCloseLauncher { get { return autoCloseLauncher; } set { SetValue(ref autoCloseLauncher, value, () => LauncherSettings.CloseLauncherAutomatically = value); } } - - /// <summary> - /// Gets or Sets the visibility status of this instance. - /// </summary> - public bool IsVisible { get { return isVisible; } set { SetValue(ref isVisible, value); } } - - public CommandBase InstallLatestVersionCommand { get; } - - public CommandBase OpenUrlCommand { get; } - - public CommandBase ReconnectCommand { get; } - - public CommandBase StartStudioCommand { get; } - - public CommandBase CheckDeprecatedSourcesCommand { get; } - - private async Task FetchOnlineData() - { - // We ensure that the self-updater task starts once the app is running because it might invoke dialogs. - IsSynchronizing = true; - await Task.Run(async () => - { - await RetrieveLocalStrideVersions(); - await RunLockTask(async () => - { - try - { - await SelfUpdater.SelfUpdate(ServiceProvider, store); - } - catch (Exception e) - { - var message = $@"**An error occurred while updating the launcher. If the problem persists, please reinstall this application.** -### Log -``` -{LogMessages} -``` - -### Exception -``` -{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} -```"; - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); - // We do not want our users to use the old launcher when a new one is available. - if (e is not HttpRequestException) // Prevent launcher closing when the user does not have internet access - Environment.Exit(1); - } - }); - // Run news task early so that it can run while we fetch package versions - var newsTask = FetchNewsPages(); - - await RetrieveServerStrideVersions(); - await VsixPackage2019.UpdateFromStore(); - await VsixPackage2022.UpdateFromStore(); - await CheckForFirstInstall(); - - await newsTask; - }); - IsSynchronizing = false; - } - - internal void LoadRecentProjects() - { - lock (RecentProjects) - { - RecentProjects.Clear(); - foreach (var mruFile in GameStudioSettings.GetMostRecentlyUsed()) - { - RecentProjects.Add(new RecentProjectViewModel(this, mruFile)); - } - } - } - - public async Task RetrieveAllStrideVersions() - { - Dispatcher.Invoke(() => IsSynchronizing = true); - await RetrieveLocalStrideVersions(); - await RetrieveServerStrideVersions(); - Dispatcher.Invoke(() => IsSynchronizing = false); - } - - private class ReferencedPackageEqualityComparer : IEqualityComparer<NugetLocalPackage> - { - public static readonly ReferencedPackageEqualityComparer Instance = new ReferencedPackageEqualityComparer(); - - private ReferencedPackageEqualityComparer() { } - - public bool Equals(NugetLocalPackage x, NugetLocalPackage y) - => (ReferenceEquals(x, y)) || ((!ReferenceEquals(x, null)) && (!ReferenceEquals(y, null)) && (x.Id == y.Id) && (x.Version.ToString() == y.Version.ToString())); - - public int GetHashCode([DisallowNull] NugetLocalPackage obj) - => (obj.Id.GetHashCode() ^ obj.Version.ToString().GetHashCode()); - } - - private HashSet<NugetLocalPackage> referencedPackages = new(ReferencedPackageEqualityComparer.Instance); - - private async Task RemoveUnusedPackages(IEnumerable<NugetLocalPackage> mainPackages) - { - var previousReferencedPackages = referencedPackages; - referencedPackages = new HashSet<NugetLocalPackage>(ReferencedPackageEqualityComparer.Instance); - foreach (var mainPackage in mainPackages) - { - await FindReferencedPackages(mainPackage); - } - foreach (var package in previousReferencedPackages.Where(package => !referencedPackages.Contains(package))) - { - await store.UninstallPackage(package, null); - } - } - - private async Task FindReferencedPackages(NugetLocalPackage package) - { - foreach (var dependency in package.Dependencies) - { - string prefix = dependency.Item1.Split('.', 2)[0]; - if (prefix is not "Stride" and not "Xenko") - { - continue; - } - NugetLocalPackage dependencyPackage = store.FindLocalPackage(dependency.Item1, dependency.Item2); - if (dependencyPackage == null || referencedPackages.Contains(dependencyPackage)) - { - continue; - } - referencedPackages.Add(dependencyPackage); - await FindReferencedPackages(dependencyPackage); - } - } - - public async Task RetrieveLocalStrideVersions() - { - List<RecentProjectViewModel> currentRecentProjects; - lock (RecentProjects) - { - currentRecentProjects = new List<RecentProjectViewModel>(RecentProjects); - } - try - { - var localPackages = await RunLockTask(() => store.GetPackagesInstalled(store.MainPackageIds).FilterStrideMainPackages().OrderByDescending(p => p.Version).ToList()); - lock (objectLock) - { - // Try to remove unused Stride/Xenko packages after uninstall or update - try - { - Task.WaitAll(RemoveUnusedPackages(localPackages)); - } - catch (Exception e) - { - var message = $@"**Failed to remove unused NuGet package(s).** - -### Exception -``` -{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} -```"; - Task.WaitAll(ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Warning)); - } - - // Retrieve all local packages - var packages = localPackages.Where(p => !store.IsDevRedirectPackage(p)).GroupBy(p => $"{p.Version.Version.Major}.{p.Version.Version.Minor}", p => p); - var updatedLocalPackages = new HashSet<StrideStoreVersionViewModel>(); - foreach (var package in packages) - { - var localPackage = package.FirstOrDefault(); - if (localPackage != null) - { - // Find if we already have this package in our list - int index = strideVersions.BinarySearch(Tuple.Create(localPackage.Version.Version.Major, localPackage.Version.Version.Minor)); - StrideStoreVersionViewModel version; - if (index < 0) - { - // If not, add it - version = new StrideStoreVersionViewModel(this, store, localPackage, localPackage.Id, localPackage.Version.Version.Major, localPackage.Version.Version.Minor); - Dispatcher.Invoke(() => strideVersions.Add(version)); - } - else - { - version = (StrideStoreVersionViewModel)strideVersions[index]; - } - version.UpdateLocalPackage(localPackage, package); - updatedLocalPackages.Add(version); - } - } - - // Update versions that are not installed locally anymore - Dispatcher.Invoke(() => - { - foreach (var strideUninstalledVersion in strideVersions.OfType<StrideStoreVersionViewModel>().Where(x => !updatedLocalPackages.Contains(x))) - strideUninstalledVersion.UpdateLocalPackage(null, Array.Empty<NugetLocalPackage>()); - }); - - // Update the active version if it is now invalid. - if (ActiveVersion == null || !strideVersions.Contains(ActiveVersion) || !ActiveVersion.CanDelete) - ActiveVersion = StrideVersions.FirstOrDefault(x => x.CanDelete); - - if (!lastActiveVersionRestored) - { - var restoredVersion = StrideVersions.FirstOrDefault(x => x.CanDelete && x.Name == LauncherSettings.ActiveVersion); - if (restoredVersion != null) - { - ActiveVersion = restoredVersion; - lastActiveVersionRestored = true; - } - } - } - - var devPackages = localPackages.Where(store.IsDevRedirectPackage); - Dispatcher.Invoke(() => strideVersions.RemoveWhere(x => x is StrideDevVersionViewModel)); - foreach (var package in devPackages) - { - try - { - var realPath = store.GetRealPath(package); - var version = new StrideDevVersionViewModel(this, store, package, realPath, true); - Dispatcher.Invoke(() => strideVersions.Add(version)); - } - catch (Exception e) - { - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.ErrorDevRedirect, e), MessageBoxButton.OK, MessageBoxImage.Information); - } - } - } - catch (Exception e) - { - // TODO: error - e.Ignore(); - } - finally - { - Dispatcher.Invoke(() => - { - foreach (var project in currentRecentProjects) - { - // Manually discarding the possibility to upgrade from 1.0 - if (project.StrideVersionName == "1.0") - continue; - - project.CompatibleVersions.Clear(); - foreach (var version in StrideVersions) - { - // We suppose all dev versions are compatible with any project. - if (version is StrideDevVersionViewModel) - project.CompatibleVersions.Add(version); - - if (version is StrideStoreVersionViewModel storeVersion && storeVersion.CanDelete) - { - // Discard the version that matches the recent project version - if (project.StrideVersion == new Version(storeVersion.Version.Version.Major, storeVersion.Version.Version.Minor)) - continue; - - // Discard the versions that are anterior to the recent project version - if (project.StrideVersion > storeVersion.Version.Version) - continue; - - project.CompatibleVersions.Add(version); - } - } - } - }); - } - } - - private async Task RetrieveServerStrideVersions() - { - try - { - var serverPackages = await RunLockTask(() => store - .FindSourcePackages(store.MainPackageIds, CancellationToken.None).Result - .FilterStrideMainPackages() - .Where(p => !store.IsDevRedirectPackage(p)) - .OrderByDescending(p => p.Version) - .ToList()); - - // Check if we could connect to the server - var wasOffline = IsOffline; - IsOffline = serverPackages.Count == 0; - - // Inform the user if we just switched offline - if (IsOffline && !wasOffline) - { - var message = $@"**{Strings.ErrorOfflineMode}** -### Log -``` -{LogMessages} -```"; - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); - } - - // We are offline, let's stop here - if (IsOffline) - return; - - lock (objectLock) - { - // Retrieve all server packages (ignoring dev ones) - var packages = serverPackages - //.Where(x => !string.Equals(x.Source, Environment.ExpandEnvironmentVariables(store.DevSource), StringComparison.OrdinalIgnoreCase)) - .GroupBy(p => $"{p.Version.Version.Major}.{p.Version.Version.Minor}", p => p); - foreach (var package in packages) - { - var serverPackage = package.FirstOrDefault(); - if (serverPackage != null) - { - // Find if we already have this package in our list - int index = strideVersions.BinarySearch(Tuple.Create(serverPackage.Version.Version.Major, serverPackage.Version.Version.Minor)); - StrideStoreVersionViewModel version; - if (index < 0) - { - // If not, add it - version = new StrideStoreVersionViewModel(this, store, null, serverPackage.Id, serverPackage.Version.Version.Major, serverPackage.Version.Version.Minor); - Dispatcher.Invoke(() => strideVersions.Add(version)); - } - else - { - // If yes, update it and remove it from the list of old version - version = (StrideStoreVersionViewModel)strideVersions[index]; - } - version.UpdateServerPackage(serverPackage, package); - } - } - } - } - catch (Exception e) - { - // TODO: error - e.Ignore(); - } - finally - { - Dispatcher.Invoke(() => - { - // Allow to install the latest version if any version is found - var latestVersion = strideVersions.FirstOrDefault(); - if (latestVersion != null) - { - // Latest version not installed and can be downloaded - if (latestVersion.CanBeDownloaded) - InstallLatestVersionCommand.IsEnabled = !latestVersion.CanDelete && latestVersion.CanBeDownloaded; - } - - OnPropertyChanging(nameof(ActiveDocumentationPages)); - OnPropertyChanged(nameof(ActiveDocumentationPages)); - }); - } - } - - public async Task CheckForFirstInstall() - { - const string prerequisitesRunTaskName = "PrerequisitesRun"; - - if (!HasDoneTask(prerequisitesRunTaskName)) - { - foreach (var version in StrideVersions.OfType<StrideStoreVersionViewModel>().Where(x => x.CanDelete)) - { - await version.RunPrerequisitesInstaller(); - } - SaveTaskAsDone(prerequisitesRunTaskName); - } - - bool firstInstall = StrideVersions.All(x => !x.CanDelete) && StrideVersions.Any(x => x.CanBeDownloaded); - - await Dispatcher.InvokeTask(async () => - { - if (firstInstall) - { - var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.AskInstallVersion, MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - var versionToInstall = StrideVersions.First(x => x.CanBeDownloaded); - await versionToInstall.Download(true); - - // if VS2022+ is installed (version 17.x+) - if (!VsixPackage2022.IsLatestVersionInstalled && VsixPackage2022.CanBeDownloaded && VisualStudioVersions.AvailableInstances.Any(ide => ide.InstallationVersion.Major >= 17)) - { - result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2022"), MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - await VsixPackage2022.ExecuteAction(); - } - } - - // if VS2019 is installed (version 16.x) - if (!VsixPackage2019.IsLatestVersionInstalled && VsixPackage2019.CanBeDownloaded && VisualStudioVersions.AvailableInstances.Any(ide => ide.InstallationVersion.Major == 16)) - { - result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2019"), MessageBoxButton.YesNo, MessageBoxImage.Question); - if (result == MessageBoxResult.Yes) - { - await VsixPackage2019.ExecuteAction(); - } - } - } - } - }); - } - - /// <summary> - /// Execute action <paramref name="action"/> under the exclusive lock <see cref="objectLock"/>. - /// </summary> - /// <typeparam name="T">Return type of action.</typeparam> - /// <param name="action">Action to be executed.</param> - /// <returns>Result of executing <paramref name="action"/>.</returns> - internal Task<T> RunLockTask<T>(Func<T> action) - { - return Task.Run(() => - { - lock (objectLock) - { - return action(); - } - }); - } - - public Task StartStudio() - { - return StartStudio(""); - } - - public async Task StartStudio(string argument) - { - if (argument == null) throw new ArgumentNullException(nameof(argument)); - if (ActiveVersion == null) - return; - - if (AutoCloseLauncher) - { - argument = $"/LauncherWindowHandle {WindowHandle} {argument}"; - } - - MetricsClient metricsForEditorBefore120 = null; - try - { - Dispatcher.Invoke(() => StartStudioCommand.IsEnabled = false); - var mainExecutable = ActiveVersion.LocateMainExecutable(); - - // If version is older than 1.2.0, than we need to log the usage of older version - if (ActiveVersion is StrideStoreVersionViewModel activeStoreVersion && activeStoreVersion.Version.Version < new Version(1, 2, 0, 0)) - { - metricsForEditorBefore120 = new MetricsClient(CommonApps.StrideEditorAppId, versionOverride: activeStoreVersion.Version.ToString()); - } - - // We set the WorkingDirectory so that global.json is properly resolved - Process.Start(new ProcessStartInfo(mainExecutable, argument) { WorkingDirectory = Path.GetDirectoryName(mainExecutable) } ); - } - catch (Exception e) - { - var message = string.Format(Strings.ErrorStartingProcess, e.FormatSummary(true)); - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); - } - finally - { - metricsForEditorBefore120?.Dispose(); - } - await Task.Delay(5000); - Dispatcher.Invoke(() => - { - StartStudioCommand.IsEnabled = ActiveVersion != null && ActiveVersion.CanStart; - //Save settings because launcher maybe have not been closed - LauncherSettings.ActiveVersion = ActiveVersion != null ? ActiveVersion.Name : ""; - LauncherSettings.Save(); - }); - } - - private async Task InstallLatestVersion() - { - var latestVersion = strideVersions.FirstOrDefault(); - // Should never happen - if (latestVersion == null || !latestVersion.CanBeDownloaded) - return; - - if (latestVersion.IsProcessing) - { - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.InstallAlreadyInProgress, MessageBoxButton.OK, MessageBoxImage.Information); - InstallLatestVersionCommand.IsEnabled = false; - } - - latestVersion.DownloadCommand.Execute(); - } - - private async Task OpenUrl(string url) - { - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - // FIXME: catch only specific exceptions? - catch (Exception) - { - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private async Task FetchNewsPages() - { - var pages = await NewsPageViewModel.FetchNewsPages(ServiceProvider, 30); - var sortedPages = pages.OrderBy(x => x.Date).Reverse().ToList(); - Dispatcher.Invoke(() => NewsPages = new ObservableList<NewsPageViewModel>(sortedPages)); - } - - public static bool HasDoneTask(string taskName) - { - var localMachine32 = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); - using (var subkey = localMachine32.OpenSubKey(@"SOFTWARE\Stride\")) - { - if (subkey != null) - { - var value = (string)subkey.GetValue(taskName); - return value != null && value.ToLowerInvariant() == "true"; - } - } - return false; - } - - public static void SaveTaskAsDone(string taskName) - { - var localMachine32 = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); - using (var subkey = localMachine32.CreateSubKey(@"SOFTWARE\Stride\")) - { - subkey?.SetValue(taskName, "True"); - } - } - - private void DisplayReleaseAnnouncement() - { - } - - void IPackagesLogger.Log(MessageLevel level, string message) - { - lock (logMessages) - { - logMessages.Add((DateTime.Now, level, message)); - } - } - - Task IPackagesLogger.LogAsync(MessageLevel level, string message) - { - ((IPackagesLogger)this).Log(level, message); - return Task.CompletedTask; - } - } -} diff --git a/sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..4a6dc3ee4f --- /dev/null +++ b/sources/launcher/Stride.Launcher/ViewModels/MainViewModel.cs @@ -0,0 +1,793 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Stride.Core.CodeEditorSupport.VisualStudio; +using Stride.Core.Extensions; +using Stride.Core.Packages; +using Stride.Core.Presentation.Collections; +using Stride.Core.Presentation.Commands; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.ViewModels; +using Stride.Core.Presentation.Windows; +using Stride.Launcher.Assets.Localization; +using Stride.Launcher.Services; + +namespace Stride.Launcher.ViewModels; + +/// <summary> +/// This class represents the root view model of the launcher. +/// </summary> +public sealed class MainViewModel : DispatcherViewModel, IPackagesLogger, IDisposable +{ + private readonly NugetStore store; + private readonly SortedObservableCollection<StrideVersionViewModel> strideVersions = []; + private readonly UninstallHelper uninstallHelper; + private readonly object objectLock = new(); + private ObservableList<NewsPageViewModel> newsPages; + private ReleaseNotesViewModel activeReleaseNotes; + private StrideVersionViewModel? activeVersion; + private bool isOffline; + private bool isSynchronizing = true; + private string currentToolTip; + private readonly List<(DateTime Time, MessageLevel Level, string Message)> logMessages = []; + private readonly ILauncherSettingsService _settings; + private bool autoCloseLauncher; + private int currentTab; + private bool lastActiveVersionRestored; + private AnnouncementViewModel announcement; + private bool isVisible; + private bool showBetaVersions; + + internal ILauncherSettingsService Settings => _settings; + + public MainViewModel(IViewModelServiceProvider serviceProvider) + : base(serviceProvider) + { + _settings = serviceProvider.Get<ILauncherSettingsService>(); + autoCloseLauncher = _settings.CloseLauncherAutomatically; + currentTab = _settings.CurrentTab; + DependentProperties.Add("ActiveVersion", ["ActiveDocumentationPages"]); + store = Launcher.InitializeNugetStore(); + store.Logger = this; + + DisplayReleaseAnnouncement(); + + VsixPackage2019 = new(this, store, store.VsixPackageId, NugetStore.VsixSupportedVsVersion.VS2019); + VsixPackage2022 = new(this, store, store.VsixPackageId, NugetStore.VsixSupportedVsVersion.VS2022AndNext); + // Commands + InstallLatestVersionCommand = new AnonymousTaskCommand(ServiceProvider, InstallLatestVersion) { IsEnabled = false }; + OpenUrlCommand = new AnonymousTaskCommand<string>(ServiceProvider, OpenUrl); + ReconnectCommand = new AnonymousTaskCommand(ServiceProvider, async () => + { + // We are back online (or so we think) + IsOffline = false; + await FetchOnlineData(); + }); + StartStudioCommand = new AnonymousTaskCommand(ServiceProvider, StartStudio) { IsEnabled = false }; + CheckDeprecatedSourcesCommand = new AnonymousTaskCommand(ServiceProvider, async () => + { + var settings = NuGet.Configuration.Settings.LoadDefaultSettings(null); + if (NugetStore.CheckPackageSource(settings, "Stride")) + { + return; + } + // Add Stride package store (still used for Xenko up to 3.0) + if (await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.AskAddNugetDeprecatedSource, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) + { + NugetStore.UpdatePackageSource(settings, "Stride", "https://packages.stride3d.net/nuget"); + settings.SaveToDisk(); + + SelfUpdater.RestartApplication(); + } + }); + + foreach (var devVersion in _settings.DeveloperVersions) + { + var version = new StrideDevVersionViewModel(this, store, null, devVersion, false); + strideVersions.Add(version); + } + FetchOnlineData().Forget(); + LoadRecentProjects(); + uninstallHelper = new(serviceProvider, store); + GameStudioSettings.RecentProjectsUpdated += (sender, e) => Dispatcher.InvokeAsync(LoadRecentProjects).Forget(); + } + + /// <summary>Test-only constructor. Skips NuGet, network, and file-system initialisation.</summary> + internal MainViewModel(IViewModelServiceProvider serviceProvider, ILauncherSettingsService settings) + : base(serviceProvider) + { + _settings = settings; + autoCloseLauncher = settings.CloseLauncherAutomatically; + currentTab = settings.CurrentTab; + newsPages = []; + currentToolTip = string.Empty; + activeReleaseNotes = null!; + store = null!; + uninstallHelper = null!; + } + + public void Dispose() + { + uninstallHelper.Dispose(); + } + + public static IntPtr WindowHandle { get; set; } + + public IEnumerable<StrideVersionViewModel> StrideVersions => strideVersions; + + public bool ShowBetaVersions { get { return showBetaVersions; } set { SetValue(ref showBetaVersions, value); } } + + public VsixVersionViewModel VsixPackage2019 { get; } + + public VsixVersionViewModel VsixPackage2022 { get; } + + public StrideVersionViewModel? ActiveVersion + { + get { return activeVersion; } + set + { + if (SetValue(ref activeVersion, value)) + { + Dispatcher.InvokeAsync(() => StartStudioCommand.IsEnabled = value?.CanStart ?? false); + } + } + } + + public ObservableList<RecentProjectViewModel> RecentProjects { get; } = []; + + public ObservableList<NewsPageViewModel> NewsPages { get { return newsPages; } private set { SetValue(ref newsPages, value); } } + + public ReleaseNotesViewModel ActiveReleaseNotes { get { return activeReleaseNotes; } set { SetValue(ref activeReleaseNotes, value); } } + + public ObservableList<DocumentationPageViewModel> ActiveDocumentationPages => ActiveVersion.Yield().Concat(StrideVersions).OfType<StrideStoreVersionViewModel>().FirstOrDefault()?.DocumentationPages; + + public AnnouncementViewModel Announcement + { + get { return announcement; } + set + { + var previous = announcement; + if (SetValue(ref announcement, value)) + { + if (previous is not null) + previous.PropertyChanged -= OnAnnouncementValidated; + if (value is not null) + value.PropertyChanged += OnAnnouncementValidated; + } + } + } + + private void OnAnnouncementValidated(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AnnouncementViewModel.Validated) && announcement?.Validated == true) + Announcement = null; + } + + public bool IsOffline { get { return isOffline; } set { SetValue(ref isOffline, value); } } + + public bool IsSynchronizing { get { return isSynchronizing; } set { SetValue(ref isSynchronizing, value); } } + + public string CurrentToolTip { get { return currentToolTip; } set { SetValue(ref currentToolTip, value); } } + + public string LogMessages + { + get + { + lock (logMessages) + { + if (logMessages.Count == 0) + return "Empty"; + return string.Join(Environment.NewLine, logMessages.Select(x => $"[{x.Time:HH:mm:ss}] {x.Level}: {x.Message}")); + } + } + } + + public bool AutoCloseLauncher { get { return autoCloseLauncher; } set { SetValue(ref autoCloseLauncher, value, () => _settings.CloseLauncherAutomatically = value); } } + + public string PreferredFramework + { + get => _settings.PreferredFramework; + set + { + if (_settings.PreferredFramework != value) + { + _settings.PreferredFramework = value; + _settings.Save(); + } + } + } + + public int CurrentTab + { + get => currentTab; + set + { + if (SetValue(ref currentTab, value)) + { + _settings.CurrentTab = value; + _settings.Save(); + } + } + } + + /// <summary> + /// Gets or Sets the visibility status of this instance. + /// </summary> + public bool IsVisible { get { return isVisible; } set { SetValue(ref isVisible, value); } } + + public CommandBase InstallLatestVersionCommand { get; } + + public CommandBase OpenUrlCommand { get; } + + public CommandBase ReconnectCommand { get; } + + public CommandBase StartStudioCommand { get; } + + public CommandBase CheckDeprecatedSourcesCommand { get; } + + private async Task FetchOnlineData() + { + // We ensure that the self-updater task starts once the app is running because it might invoke dialogs. + IsSynchronizing = true; + await Task.Run(async () => + { + await RetrieveLocalStrideVersions(); + await RunLockTask(async () => + { + try + { + await SelfUpdater.SelfUpdate(ServiceProvider, store); + } + catch (Exception e) + { + var message = $@"**An error occurred while updating the launcher. If the problem persists, please reinstall this application.** +### Log +``` +{LogMessages} +``` + +### Exception +``` +{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} +```"; + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); + // We do not want our users to use the old launcher when a new one is available. + if (e is not HttpRequestException) // Prevent launcher closing when the user does not have internet access + Environment.Exit(1); + } + }); + // Run news task early so that it can run while we fetch package versions + var newsTask = FetchNewsPages(); + + await RetrieveServerStrideVersions(); + await VsixPackage2019.UpdateFromStore(); + await VsixPackage2022.UpdateFromStore(); + await CheckForFirstInstall(); + + await newsTask; + }); + IsSynchronizing = false; + } + + internal void LoadRecentProjects() + { + lock (RecentProjects) + { + RecentProjects.Clear(); + foreach (var mruFile in GameStudioSettings.GetMostRecentlyUsed()) + { + RecentProjects.Add(new(this, mruFile)); + } + } + } + + public async Task RetrieveAllStrideVersions() + { + Dispatcher.Invoke(() => IsSynchronizing = true); + await RetrieveLocalStrideVersions(); + await RetrieveServerStrideVersions(); + Dispatcher.Invoke(() => IsSynchronizing = false); + } + + private class ReferencedPackageEqualityComparer : IEqualityComparer<NugetLocalPackage> + { + public static readonly ReferencedPackageEqualityComparer Instance = new(); + + private ReferencedPackageEqualityComparer() { } + + public bool Equals(NugetLocalPackage x, NugetLocalPackage y) + => (ReferenceEquals(x, y)) || ((!ReferenceEquals(x, null)) && (!ReferenceEquals(y, null)) && (x.Id == y.Id) && (x.Version.ToString() == y.Version.ToString())); + + public int GetHashCode([DisallowNull] NugetLocalPackage obj) + => (obj.Id.GetHashCode() ^ obj.Version.ToString().GetHashCode()); + } + + private HashSet<NugetLocalPackage> referencedPackages = new(ReferencedPackageEqualityComparer.Instance); + + private async Task RemoveUnusedPackages(IEnumerable<NugetLocalPackage> mainPackages) + { + var previousReferencedPackages = referencedPackages; + referencedPackages = new(ReferencedPackageEqualityComparer.Instance); + foreach (var mainPackage in mainPackages) + { + await FindReferencedPackages(mainPackage); + } + foreach (var package in previousReferencedPackages.Where(package => !referencedPackages.Contains(package))) + { + await store.UninstallPackage(package, null); + } + } + + private async Task FindReferencedPackages(NugetLocalPackage package) + { + foreach (var dependency in package.Dependencies) + { + string prefix = dependency.Item1.Split('.', 2)[0]; + if (prefix is not "Stride" and not "Xenko") + { + continue; + } + NugetLocalPackage dependencyPackage = store.FindLocalPackage(dependency.Item1, dependency.Item2); + if (dependencyPackage is null || !referencedPackages.Add(dependencyPackage)) + { + continue; + } + + await FindReferencedPackages(dependencyPackage); + } + } + + public async Task RetrieveLocalStrideVersions() + { + List<RecentProjectViewModel> currentRecentProjects; + lock (RecentProjects) + { + currentRecentProjects = new(RecentProjects); + } + try + { + var localPackages = await RunLockTask(() => store.GetPackagesInstalled(store.MainPackageIds).FilterStrideMainPackages().OrderByDescending(p => p.Version).ToList()); + lock (objectLock) + { + // Try to remove unused Stride/Xenko packages after uninstall or update + try + { + Task.WaitAll(RemoveUnusedPackages(localPackages)); + } + catch (Exception e) + { + var message = $@"**Failed to remove unused NuGet package(s).** + +### Exception +``` +{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} +```"; + Task.WaitAll(ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Warning)); + } + + // Retrieve all local packages + var packages = localPackages.Where(p => !store.IsDevRedirectPackage(p)).GroupBy(p => $"{p.Version.Version.Major}.{p.Version.Version.Minor}", p => p); + var updatedLocalPackages = new HashSet<StrideStoreVersionViewModel>(); + foreach (var package in packages) + { + var localPackage = package.FirstOrDefault(); + if (localPackage is not null) + { + // Find if we already have this package in our list + int index = strideVersions.BinarySearch(Tuple.Create(localPackage.Version.Version.Major, localPackage.Version.Version.Minor)); + StrideStoreVersionViewModel version; + if (index < 0) + { + // If not, add it + version = new(this, store, localPackage, localPackage.Id, localPackage.Version.Version.Major, localPackage.Version.Version.Minor); + Dispatcher.Invoke(() => strideVersions.Add(version)); + } + else + { + version = (StrideStoreVersionViewModel)strideVersions[index]; + } + version.UpdateLocalPackage(localPackage, package); + updatedLocalPackages.Add(version); + } + } + + // Update versions that are not installed locally anymore + Dispatcher.Invoke(() => + { + foreach (var strideUninstalledVersion in strideVersions.OfType<StrideStoreVersionViewModel>().Where(x => !updatedLocalPackages.Contains(x))) + strideUninstalledVersion.UpdateLocalPackage(null, Array.Empty<NugetLocalPackage>()); + }); + + // Update the active version if it is now invalid. + if (ActiveVersion is null || !strideVersions.Contains(ActiveVersion) || !ActiveVersion.CanDelete) + ActiveVersion = StrideVersions.FirstOrDefault(x => x.CanDelete); + + if (!lastActiveVersionRestored) + { + var restoredVersion = StrideVersions.FirstOrDefault(x => x.CanDelete && x.Name == _settings.ActiveVersion); + if (restoredVersion is not null) + { + ActiveVersion = restoredVersion; + lastActiveVersionRestored = true; + } + } + } + + var devPackages = localPackages.Where(store.IsDevRedirectPackage); + Dispatcher.Invoke(() => strideVersions.RemoveWhere(x => x is StrideDevVersionViewModel)); + foreach (var package in devPackages) + { + try + { + var realPath = store.GetRealPath(package); + var version = new StrideDevVersionViewModel(this, store, package, realPath, true); + await Dispatcher.InvokeAsync(() => strideVersions.Add(version)); + } + catch (Exception e) + { + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.ErrorDevRedirect, e), MessageBoxButton.OK, MessageBoxImage.Information); + } + } + } + catch (Exception e) + { + // TODO: error + e.Ignore(); + } + finally + { + await Dispatcher.InvokeAsync(() => + { + foreach (var project in currentRecentProjects) + { + // Manually discarding the possibility to upgrade from 1.0 + if (project.StrideVersionName == "1.0") + continue; + + project.CompatibleVersions.Clear(); + foreach (var version in StrideVersions) + { + // We suppose all dev versions are compatible with any project. + if (version is StrideDevVersionViewModel) + project.CompatibleVersions.Add(version); + + if (version is StrideStoreVersionViewModel { CanDelete: true } storeVersion) + { + // Discard the version that matches the recent project version + if (project.StrideVersion == new Version(storeVersion.Version.Version.Major, storeVersion.Version.Version.Minor)) + continue; + + // Discard the versions that are anterior to the recent project version + if (project.StrideVersion > storeVersion.Version.Version) + continue; + + project.CompatibleVersions.Add(version); + } + } + } + }); + } + } + + private async Task RetrieveServerStrideVersions() + { + try + { + var serverPackages = await RunLockTask(() => store + .FindSourcePackages(store.MainPackageIds, CancellationToken.None).Result + .FilterStrideMainPackages() + .Where(p => !store.IsDevRedirectPackage(p)) + .OrderByDescending(p => p.Version) + .ToList()); + + // Check if we could connect to the server + var wasOffline = IsOffline; + IsOffline = serverPackages.Count == 0; + + // Inform the user if we just switched offline + if (IsOffline && !wasOffline) + { + var message = + $""" + **{Strings.ErrorOfflineMode}** + ### Log + ``` + {LogMessages} + ``` + """; + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); + } + + // We are offline, let's stop here + if (IsOffline) + return; + + lock (objectLock) + { + // Retrieve all server packages (ignoring dev ones) + var packages = serverPackages + //.Where(x => !string.Equals(x.Source, Environment.ExpandEnvironmentVariables(store.DevSource), StringComparison.OrdinalIgnoreCase)) + .GroupBy(p => $"{p.Version.Version.Major}.{p.Version.Version.Minor}", p => p); + foreach (var package in packages) + { + var serverPackage = package.FirstOrDefault(); + if (serverPackage is not null) + { + // Find if we already have this package in our list + int index = strideVersions.BinarySearch(Tuple.Create(serverPackage.Version.Version.Major, serverPackage.Version.Version.Minor)); + StrideStoreVersionViewModel version; + if (index < 0) + { + // If not, add it + version = new(this, store, null, serverPackage.Id, serverPackage.Version.Version.Major, serverPackage.Version.Version.Minor); + Dispatcher.Invoke(() => strideVersions.Add(version)); + } + else + { + // If yes, update it and remove it from the list of old version + version = (StrideStoreVersionViewModel)strideVersions[index]; + } + version.UpdateServerPackage(serverPackage, package); + } + } + } + } + catch (Exception e) + { + // TODO: error + e.Ignore(); + } + finally + { + await Dispatcher.InvokeAsync(() => + { + // Allow to install the latest version if any version is found + var latestVersion = strideVersions.FirstOrDefault(); + if (latestVersion is not null) + { + // Latest version not installed and can be downloaded + if (latestVersion.CanBeDownloaded) + InstallLatestVersionCommand.IsEnabled = latestVersion is { CanDelete: false, CanBeDownloaded: true }; + } + + OnPropertyChanging(nameof(ActiveDocumentationPages)); + OnPropertyChanged(nameof(ActiveDocumentationPages)); + }); + } + } + + public async Task CheckForFirstInstall() + { + const string prerequisitesRunTaskName = "PrerequisitesRun"; + + if (!HasDoneTask(prerequisitesRunTaskName)) + { + foreach (var version in StrideVersions.OfType<StrideStoreVersionViewModel>().Where(x => x.CanDelete)) + { + await version.RunPrerequisitesInstaller(); + } + SaveTaskAsDone(prerequisitesRunTaskName); + } + + bool firstInstall = StrideVersions.All(x => !x.CanDelete) && StrideVersions.Any(x => x.CanBeDownloaded); + + await Dispatcher.InvokeTask(async () => + { + if (firstInstall) + { + var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.AskInstallVersion, MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + var versionToInstall = StrideVersions.First(x => x.CanBeDownloaded); + await versionToInstall.Download(true); + + // if VS2022+ is installed (version 17.x+) + if (VsixPackage2022 is { IsLatestVersionInstalled: false, CanBeDownloaded: true } && VisualStudioVersions.AvailableInstances.Any(ide => ide.InstallationVersion.Major >= 17)) + { + result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2022"), MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + await VsixPackage2022.ExecuteAction(); + } + } + + // if VS2019 is installed (version 16.x) + if (VsixPackage2019 is { IsLatestVersionInstalled: false, CanBeDownloaded: true } && VisualStudioVersions.AvailableInstances.Any(ide => ide.InstallationVersion.Major == 16)) + { + result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2019"), MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + await VsixPackage2019.ExecuteAction(); + } + } + } + } + }); + } + + /// <summary> + /// Execute action <paramref name="action"/> under the exclusive lock <see cref="objectLock"/>. + /// </summary> + /// <typeparam name="T">Return type of action.</typeparam> + /// <param name="action">Action to be executed.</param> + /// <returns>Result of executing <paramref name="action"/>.</returns> + internal Task<T> RunLockTask<T>(Func<T> action) + { + return Task.Run(() => + { + lock (objectLock) + { + return action(); + } + }); + } + + public Task StartStudio() + { + return StartStudio(""); + } + + public async Task StartStudio(string argument) + { + ArgumentNullException.ThrowIfNull(argument); + + if (ActiveVersion is null) + return; + + if (AutoCloseLauncher) + { + // WindowHandle is a Win32 HWND populated by MainWindow.OnOpened on Windows only. + // On Linux it stays IntPtr.Zero — Game Studio's parser tolerates 0. See + // MainWindow.OnOpened for what needs to change when xplat-GameStudio lands. + argument = $"/LauncherWindowHandle {WindowHandle} {argument}"; + } + + try + { + Dispatcher.Invoke(() => StartStudioCommand.IsEnabled = false); + var mainExecutable = ActiveVersion.LocateMainExecutable(); + + // We set the WorkingDirectory so that global.json is properly resolved + switch (Path.GetExtension(mainExecutable)) + { + case ".dll": + argument = $"{mainExecutable} {argument}"; + Process.Start(new ProcessStartInfo("dotnet", argument) + { + WorkingDirectory = Path.GetDirectoryName(mainExecutable) + }); + break; + + default: + Process.Start(new ProcessStartInfo(mainExecutable, argument) + { + WorkingDirectory = Path.GetDirectoryName(mainExecutable) + }); + break; + } + } + catch (Exception e) + { + var message = string.Format(Strings.ErrorStartingProcess, e.FormatSummary(true)); + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); + } + + await Task.Delay(5000); + await Dispatcher.InvokeAsync(() => + { + StartStudioCommand.IsEnabled = ActiveVersion is not null && ActiveVersion.CanStart; + //Save settings because launcher maybe have not been closed + _settings.ActiveVersion = ActiveVersion is not null ? ActiveVersion.Name : ""; + _settings.Save(); + }); + } + + private async Task InstallLatestVersion() + { + var latestVersion = strideVersions.FirstOrDefault(); + // Should never happen + if (latestVersion is null || !latestVersion.CanBeDownloaded) + return; + + if (latestVersion.IsProcessing) + { + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.InstallAlreadyInProgress, MessageBoxButton.OK, MessageBoxImage.Information); + InstallLatestVersionCommand.IsEnabled = false; + return; + } + + latestVersion.DownloadCommand.Execute(); + } + + private async Task OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + // FIXME: catch only specific exceptions? + catch (Exception) + { + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private async Task FetchNewsPages() + { + var pages = await NewsPageViewModel.FetchNewsPages(ServiceProvider, 30); + var sortedPages = pages.OrderBy(x => x.Date).Reverse().ToList(); + Dispatcher.Invoke(() => NewsPages = new(sortedPages)); + } + + public bool HasDoneTask(string taskName) => _settings.IsTaskCompleted(taskName); + + public void SaveTaskAsDone(string taskName) => _settings.MarkTaskCompleted(taskName); + + private const int KeepOpenResult = 0; + private const int CloseAnywayResult = 1; + + /// <summary> + /// Determines whether the launcher can close right now, prompting the user to confirm + /// if any Stride version is currently being downloaded or installed, and persists + /// settings before returning <c>true</c>. + /// </summary> + /// <returns><c>true</c> if the caller should proceed with closing the window; <c>false</c> if the user chose to keep the launcher open.</returns> + public async Task<bool> TryCloseAsync() + { + if (StrideVersions.Any(v => v.IsProcessing)) + { + var buttons = new[] + { + new DialogButtonInfo + { + Content = Strings.CloseAnyway, + Result = CloseAnywayResult, + }, + new DialogButtonInfo + { + Content = Strings.KeepLauncherOpen, + IsDefault = true, + IsCancel = true, + Key = "Escape", + Result = KeepOpenResult, + }, + }; + + var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync( + Strings.CloseLauncherInProgressMessage, + buttons, + MessageBoxImage.Warning); + + if (result == KeepOpenResult) + { + return false; + } + } + + _settings.ActiveVersion = ActiveVersion?.Name ?? string.Empty; + _settings.Save(); + return true; + } + + internal void AddVersionForTest(StrideVersionViewModel version) => strideVersions.Add(version); + + private void DisplayReleaseAnnouncement() + { + } + + void IPackagesLogger.Log(MessageLevel level, string message) + { + lock (logMessages) + { + logMessages.Add((DateTime.Now, level, message)); + } + } + + Task IPackagesLogger.LogAsync(MessageLevel level, string message) + { + ((IPackagesLogger)this).Log(level, message); + return Task.CompletedTask; + } +} diff --git a/sources/launcher/Stride.Launcher/ViewModels/NewsPageViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/NewsPageViewModel.cs index 567571128f..55b4108aae 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/NewsPageViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/NewsPageViewModel.cs @@ -1,112 +1,110 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; + using System.Diagnostics; using System.Globalization; -using System.Net.Http; -using System.Threading.Tasks; using System.Xml; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed class NewsPageViewModel : DispatcherViewModel { - internal class NewsPageViewModel : DispatcherViewModel + private static readonly HttpClient httpClient = new(); + + public NewsPageViewModel(IViewModelServiceProvider serviceProvider) + : base(serviceProvider) + { + OpenUrlCommand = new AnonymousTaskCommand(ServiceProvider, OpenUrl); + } + + private async Task OpenUrl() { - private static readonly HttpClient httpClient = new(); + if (Url is null) return; - public NewsPageViewModel(IViewModelServiceProvider serviceProvider) - : base(serviceProvider) + try { - OpenUrlCommand = new AnonymousTaskCommand(ServiceProvider, OpenUrl); + Process.Start(new ProcessStartInfo(Url) { UseShellExecute = true }); } - - private async Task OpenUrl() + catch (Exception) { - try - { - Process.Start(new ProcessStartInfo(Url) { UseShellExecute = true }); - } - catch (Exception) - { - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); - } + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error); } + } - /// <summary> - /// Gets or sets the title of this documentation page. - /// </summary> - public string Title { get; set; } + /// <summary> + /// Gets or sets the title of this documentation page. + /// </summary> + public string? Title { get; set; } - /// <summary> - /// Gets or sets the description of this documentation page. - /// </summary> - public string Description { get; set; } + /// <summary> + /// Gets or sets the description of this documentation page. + /// </summary> + public string? Description { get; set; } - /// <summary> - /// Gets or sets the url of this documentation page. - /// </summary> - public string Url { get; set; } + /// <summary> + /// Gets or sets the url of this documentation page. + /// </summary> + public string? Url { get; set; } - /// <summary> - /// Gets or sets the url of this documentation page. - /// </summary> - public DateTime Date { get; set; } + /// <summary> + /// Gets or sets the url of this documentation page. + /// </summary> + public DateTime Date { get; set; } - /// <summary> - /// Gets a command that will open the documentation page in the default web browser. - /// </summary> - public ICommandBase OpenUrlCommand { get; private set; } + /// <summary> + /// Gets a command that will open the documentation page in the default web browser. + /// </summary> + public ICommandBase OpenUrlCommand { get; private set; } - public static async Task<List<NewsPageViewModel>> FetchNewsPages(IViewModelServiceProvider serviceProvider, int maxCount) + public static async Task<List<NewsPageViewModel>> FetchNewsPages(IViewModelServiceProvider serviceProvider, int maxCount) + { + var result = new List<NewsPageViewModel>(); + try { - var result = new List<NewsPageViewModel>(); - try - { - using var response = await httpClient.GetAsync(Urls.RssFeed); - response.EnsureSuccessStatusCode(); - var rss = await response.Content.ReadAsStreamAsync(); + using var response = await httpClient.GetAsync(Urls.RssFeed); + response.EnsureSuccessStatusCode(); + var rss = await response.Content.ReadAsStreamAsync(); - if (rss.Length == 0) - return result; + if (rss.Length == 0) + return result; - int count = 0; - using XmlReader rssReader = XmlReader.Create(rss); - rssReader.MoveToContent(); - while (rssReader.ReadToFollowing("item") && count < maxCount) + int count = 0; + using XmlReader rssReader = XmlReader.Create(rss, new XmlReaderSettings { Async = true }); + await rssReader.MoveToContentAsync(); + while (rssReader.ReadToFollowing("item") && count < maxCount) + { + rssReader.ReadToFollowing("title"); + string? title = await rssReader.ReadAsync() ? rssReader.Value : null; + rssReader.ReadToFollowing("description"); + string? description = await rssReader.ReadAsync() ? rssReader.Value : null; + rssReader.ReadToFollowing("pubDate"); + var date = new DateTime(); + bool dateValid = await rssReader.ReadAsync() && DateTime.TryParseExact(rssReader.Value, "ddd, dd MMM yyyy HH:mm:ss zz00", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + rssReader.ReadToFollowing("link"); + string? link = await rssReader.ReadAsync() ? rssReader.Value : null; + if (dateValid && title is not null && link is not null && description is not null) { - rssReader.ReadToFollowing("title"); - string title = rssReader.Read() ? rssReader.Value : null; - rssReader.ReadToFollowing("description"); - string description = rssReader.Read() ? rssReader.Value : null; - rssReader.ReadToFollowing("pubDate"); - var date = new DateTime(); - bool dateValid = rssReader.Read() && DateTime.TryParseExact(rssReader.Value, "ddd, dd MMM yyyy HH:mm:ss zz00", CultureInfo.InvariantCulture, DateTimeStyles.None, out date); - rssReader.ReadToFollowing("link"); - string link = rssReader.Read() ? rssReader.Value : null; - if (dateValid && title != null && link != null && description != null) + var page = new NewsPageViewModel(serviceProvider) { - var page = new NewsPageViewModel(serviceProvider) - { - Title = title, - Url = link, - Description = description, - Date = date - }; - result.Add(page); - ++count; - } + Title = title, + Url = link, + Description = description, + Date = date + }; + result.Add(page); + ++count; } } - catch (Exception) - { - result.Clear(); - } - - return result; } + catch (Exception) + { + result.Clear(); + } + + return result; } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/PackageVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/PackageVersionViewModel.cs index 495fc5d33b..f7bc1f9353 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/PackageVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/PackageVersionViewModel.cs @@ -1,304 +1,255 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Threading.Tasks; + +using System.Diagnostics; using Stride.Core.Extensions; using Stride.Core.Packages; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; -using Stride.LauncherApp.Services; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +/// <summary> +/// A view model class that represents a Nuget package, as it exists both locally and on a remote server. +/// </summary> +public abstract class PackageVersionViewModel : DispatcherViewModel { + protected NugetLocalPackage? LocalPackage; + protected NugetServerPackage? ServerPackage; + private ProgressAction currentProgressAction; + private int currentProgress; + private bool isProcessing; + private bool canBeDownloaded; + private bool canDelete; + private string? currentProcessStatus; + /// <summary> - /// A view model class that represents a Nuget package, as it exists both locally and on a remote server. + /// Initializes a new instance of the <see cref="PackageVersionViewModel"/> class. /// </summary> - internal abstract class PackageVersionViewModel : DispatcherViewModel + /// <param name="launcher">The parent <see cref="MainViewModel"/> instance.</param> + /// <param name="store">The related <see cref="NugetStore"/> instance.</param> + /// <param name="localPackage">The local package of this version, if a local package exists.</param> + internal PackageVersionViewModel(MainViewModel launcher, NugetStore store, NugetLocalPackage? localPackage) + : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) { - protected NugetLocalPackage LocalPackage; - protected NugetServerPackage ServerPackage; - private ProgressAction currentProgressAction; - private int currentProgress; - private bool isProcessing; - private bool canBeDownloaded; - private bool canDelete; - private string currentProcessStatus; - - /// <summary> - /// Initializes a new instance of the <see cref="PackageVersionViewModel"/> class. - /// </summary> - /// <param name="launcher">The parent <see cref="LauncherViewModel"/> instance.</param> - /// <param name="store">The related <see cref="NugetStore"/> instance.</param> - /// <param name="localPackage">The local package of this version, if a local package exists.</param> - internal PackageVersionViewModel(LauncherViewModel launcher, NugetStore store, NugetLocalPackage localPackage) - : base(launcher.SafeArgument("launcher").ServiceProvider) - { - Launcher = launcher ?? throw new ArgumentNullException(nameof(launcher)); - Store = store ?? throw new ArgumentNullException(nameof(store)); - LocalPackage = localPackage; - DownloadCommand = new AnonymousTaskCommand(ServiceProvider, () => Download(true)); - DeleteCommand = new AnonymousTaskCommand(ServiceProvider, () => Delete(true, true)) { IsEnabled = CanDelete }; - UpdateStatusInternal(); - } + ArgumentNullException.ThrowIfNull(launcher); + ArgumentNullException.ThrowIfNull(store); + + Launcher = launcher; + Store = store; + LocalPackage = localPackage; + DownloadCommand = new AnonymousTaskCommand(ServiceProvider, () => Download(true)); + DeleteCommand = new AnonymousTaskCommand(ServiceProvider, () => Delete(true, true)) { IsEnabled = CanDelete }; + UpdateStatusInternal(); + } - /// <summary> - /// Gets the short name of this version. - /// </summary> - public abstract string Name { get; } - - /// <summary> - /// Gets the full name of this version. - /// </summary> - public abstract string FullName { get; } - - /// <summary> - /// Gets the installation path of this version, or <c>null</c> if it is not installed. - /// </summary> - public virtual string InstallPath => LocalPackage?.Path; - - /// <summary> - /// Gets whether a download is available for this version, being an update or a first install. - /// </summary> - public virtual bool CanBeDownloaded { get { return canBeDownloaded; } private set { SetValue(ref canBeDownloaded, value); } } - - /// <summary> - /// Gets whether this package is installed and can be deleted. - /// </summary> - public virtual bool CanDelete { get { return canDelete; } private set { SetValue(ref canDelete, value); } } - - /// <summary> - /// Gets the progress of the current download, in percents. - /// </summary> - public ProgressAction CurrentProgressAction { get { return currentProgressAction; } private set { SetValue(ref currentProgressAction, value); } } - - /// <summary> - /// Gets the progress of the current download, in percents. - /// </summary> - public int CurrentProgress { get { return currentProgress; } private set { SetValue(ref currentProgress, value); } } - - /// <summary> - /// Gets whether this version is being processed, being installed, upgraded or deleted. - /// </summary> - public bool IsProcessing { get { return isProcessing; } protected set { SetValue(ref isProcessing, value); } } - - /// <summary> - /// Gets a string representing the current status while this version is being installed, upgraded or deleted. - /// </summary> - public string CurrentProcessStatus { get { return currentProcessStatus; } protected set { SetValue(ref currentProcessStatus, value); } } - - /// <summary> - /// Gets the command that will download the latest version of the associated package and deploy it. - /// </summary> - public ICommandBase DownloadCommand { get; } - - /// <summary> - /// Gets the command that will delete the associated package. - /// </summary> - public CommandBase DeleteCommand { get; } - - public LauncherViewModel Launcher { get; } - - /// <summary> - /// Gets the related <see cref="NugetStore"/> instance. - /// </summary> - protected NugetStore Store { get; } - - /// <summary> - /// Gets the message to display when an error occurs during the install of this package. - /// </summary> - protected abstract string InstallErrorMessage { get; } - - /// <summary> - /// Gets the message to display when an error occurs during the uninstall of this package. - /// </summary> - protected abstract string UninstallErrorMessage { get; } - - /// <summary> - /// Updates all the versions of this type from the store. This method should update the <see cref="LocalPackage"/> and <see cref="ServerPackage"/> - /// for each version of the same type, remove versions that do not exist anymore, and add new versions. - /// </summary> - /// <returns>A task that completes when the versions are updated.</returns> - protected abstract Task UpdateVersionsFromStore(); - - /// <summary> - /// Updates the status of this version, synchronizing the different properties and command state of the view model with the local and server packages status. - /// </summary> - protected virtual void UpdateStatus() - { - UpdateStatusInternal(); - } + /// <summary> + /// Gets the short name of this version. + /// </summary> + public abstract string Name { get; } - protected void UpdateProgress(ProgressAction action, int progress) - { - CurrentProgressAction = action; - CurrentProgress = progress; - UpdateInstallStatus(); - } + /// <summary> + /// Gets the full name of this version. + /// </summary> + public abstract string FullName { get; } - /// <summary> - /// Updates the <see cref="CurrentProcessStatus"/> property according to the <see cref="CurrentProgress"/> value. - /// </summary> - protected abstract void UpdateInstallStatus(); + /// <summary> + /// Gets the installation path of this version, or <c>null</c> if it is not installed. + /// </summary> + public virtual string? InstallPath => LocalPackage?.Path; - /// <summary> - /// Executes some actions before starting to download this version. - /// </summary> - protected virtual void BeforeDownload() - { - // Intentionally does nothing. - } + /// <summary> + /// Gets whether a download is available for this version, being an update or a first install. + /// </summary> + public virtual bool CanBeDownloaded { get { return canBeDownloaded; } private set { SetValue(ref canBeDownloaded, value); } } - /// <summary> - /// Executes some actions after downloading and installing this version. - /// </summary> - protected virtual void AfterDownload() - { - // Intentionally does nothing. - } + /// <summary> + /// Gets whether this package is installed and can be deleted. + /// </summary> + public virtual bool CanDelete { get { return canDelete; } private set { SetValue(ref canDelete, value); } } - /// <summary> - /// Downloads the latest version of this package. If a version is already in the local store, it will be deleted first. - /// </summary> - /// <param name="displayErrorMessage">Indicates whether to display error message boxes when an error occurs.</param> - /// <returns>A task that completes when the latest version has been downloaded.</returns> - /// <remarks> - /// This method will invoke, from a worker thread, <see cref="BeforeDownload"/> before doing anything, and <see cref="AfterDownload"/> - /// if the download successfully completed without exception. In every case, it will also invoke <see cref="UpdateVersionsFromStore"/> - /// before completing. - /// </remarks> - public Task Download(bool displayErrorMessage) - { - BeforeDownload(); + /// <summary> + /// Gets the progress of the current download, in percents. + /// </summary> + public ProgressAction CurrentProgressAction { get { return currentProgressAction; } private set { SetValue(ref currentProgressAction, value); } } - return Task.Run(async () => - { - IsProcessing = true; + /// <summary> + /// Gets the progress of the current download, in percents. + /// </summary> + public int CurrentProgress { get { return currentProgress; } private set { SetValue(ref currentProgress, value); } } - // Uninstall previous version first, if it exists - if (LocalPackage != null) - { - try - { - CurrentProcessStatus = null; - using var progressReport = new ProgressReport(Store, ServerPackage); - progressReport.ProgressChanged += (action, progress) => { Dispatcher.InvokeAsync(() => { UpdateProgress(action, progress); }).Forget(); }; - progressReport.UpdateProgress(ProgressAction.Delete, -1); - await Store.UninstallPackage(LocalPackage, progressReport); - CurrentProcessStatus = null; - } - catch (Exception e) - { - if (displayErrorMessage) - { - var message = $"{UninstallErrorMessage}{e.FormatSummary(true)}"; - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); - await UpdateVersionsFromStore(); - IsProcessing = false; - return; - } + /// <summary> + /// Gets whether this version is being processed, being installed, upgraded or deleted. + /// </summary> + public bool IsProcessing { get { return isProcessing; } protected set { SetValue(ref isProcessing, value); } } - IsProcessing = false; - throw; - } - } + /// <summary> + /// Gets a string representing the current status while this version is being installed, upgraded or deleted. + /// </summary> + public string? CurrentProcessStatus { get { return currentProcessStatus; } protected set { SetValue(ref currentProcessStatus, value); } } - // Then download and install the latest version. - bool downloadCompleted = false; + /// <summary> + /// Gets the command that will download the latest version of the associated package and deploy it. + /// </summary> + public ICommandBase DownloadCommand { get; } + + /// <summary> + /// Gets the command that will delete the associated package. + /// </summary> + public CommandBase DeleteCommand { get; } + + public MainViewModel Launcher { get; } + + /// <summary> + /// Gets the related <see cref="NugetStore"/> instance. + /// </summary> + protected NugetStore Store { get; } + + /// <summary> + /// Gets the message to display when an error occurs during the install of this package. + /// </summary> + protected abstract string InstallErrorMessage { get; } + + /// <summary> + /// Gets the message to display when an error occurs during the uninstall of this package. + /// </summary> + protected abstract string UninstallErrorMessage { get; } + + /// <summary> + /// Updates all the versions of this type from the store. This method should update the <see cref="LocalPackage"/> and <see cref="ServerPackage"/> + /// for each version of the same type, remove versions that do not exist anymore, and add new versions. + /// </summary> + /// <returns>A task that completes when the versions are updated.</returns> + protected abstract Task UpdateVersionsFromStore(); + + /// <summary> + /// Updates the status of this version, synchronizing the different properties and command state of the view model with the local and server packages status. + /// </summary> + protected virtual void UpdateStatus() + { + UpdateStatusInternal(); + } + + protected void UpdateProgress(ProgressAction action, int progress) + { + CurrentProgressAction = action; + CurrentProgress = progress; + UpdateInstallStatus(); + } + + /// <summary> + /// Updates the <see cref="CurrentProcessStatus"/> property according to the <see cref="CurrentProgress"/> value. + /// </summary> + protected abstract void UpdateInstallStatus(); + + /// <summary> + /// Executes some actions before starting to download this version. + /// </summary> + protected virtual void BeforeDownload() + { + // Intentionally does nothing. + } + + /// <summary> + /// Executes some actions after downloading and installing this version. + /// </summary> + protected virtual void AfterDownload() + { + // Intentionally does nothing. + } + + /// <summary> + /// Downloads the latest version of this package. If a version is already in the local store, it will be deleted first. + /// </summary> + /// <param name="displayErrorMessage">Indicates whether to display error message boxes when an error occurs.</param> + /// <returns>A task that completes when the latest version has been downloaded.</returns> + /// <remarks> + /// This method will invoke, from a worker thread, <see cref="BeforeDownload"/> before doing anything, and <see cref="AfterDownload"/> + /// if the download successfully completed without exception. In every case, it will also invoke <see cref="UpdateVersionsFromStore"/> + /// before completing. + /// </remarks> + public Task Download(bool displayErrorMessage) + { + BeforeDownload(); + + return Task.Run(async () => + { + IsProcessing = true; + Debug.Assert(ServerPackage is not null); + + // Uninstall previous version first, if it exists + if (LocalPackage is not null) + { try { - using (var progressReport = new ProgressReport(Store, ServerPackage)) - { - progressReport.ProgressChanged += (action, progress) => { Dispatcher.InvokeAsync(() => { UpdateProgress(action, progress); }).Forget(); }; - progressReport.UpdateProgress(ProgressAction.Install, -1); - MetricsHelper.NotifyDownloadStarting(ServerPackage.Id, ServerPackage.Version.ToString()); - await Store.InstallPackage(ServerPackage.Id, ServerPackage.Version, ServerPackage.TargetFrameworks, progressReport); - downloadCompleted = true; - MetricsHelper.NotifyDownloadCompleted(ServerPackage.Id, ServerPackage.Version.ToString()); - } - - AfterDownload(); + CurrentProcessStatus = null; + using var progressReport = new ProgressReport(Store, ServerPackage); + progressReport.ProgressChanged += (action, progress) => { Dispatcher.InvokeAsync(() => { UpdateProgress(action, progress); }).Forget(); }; + progressReport.UpdateProgress(ProgressAction.Delete, -1); + await Store.UninstallPackage(LocalPackage, progressReport); + CurrentProcessStatus = null; } catch (Exception e) { - if (!downloadCompleted) - MetricsHelper.NotifyDownloadFailed(ServerPackage.Id, ServerPackage.Version.ToString()); - - // Rollback: try to delete the broken package (i.e. if it is installed with NuGet but had a failure during Install scripts) - try - { - var localPackage = Store.FindLocalPackage(ServerPackage.Id, ServerPackage.Version); - if (localPackage != null) - { - await Store.UninstallPackage(localPackage, null); - } - } - catch - { - // Note: quite a bad state: rollback (uninstall) failed - // we don't display the message to not confuse the user even more with an intermediate uninstall error message before the install error message - } - if (displayErrorMessage) { - var message = $@"**{InstallErrorMessage}** -### Log -``` -{Launcher.LogMessages} -``` - -### Exception -``` -{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} -```"; + var message = $"{UninstallErrorMessage}{e.FormatSummary(true)}"; await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); + await UpdateVersionsFromStore(); + IsProcessing = false; return; } - throw; - } - finally - { - await UpdateVersionsFromStore(); + IsProcessing = false; + throw; } - }); - } - - protected async Task Delete(bool displayErrorMessage, bool askConfirmation) - { - bool proceed = !askConfirmation; - if (askConfirmation) - { - var message = string.Format(Strings.ConfirmUninstall, FullName); - var confirmResult = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.YesNo); - proceed = confirmResult == MessageBoxResult.Yes; - } - if (proceed) - { - await Task.Run(() => DeleteInternal(displayErrorMessage)); } - } - private async Task DeleteInternal(bool displayErrorMessage) - { - IsProcessing = true; + // Then download and install the latest version. + bool downloadCompleted = false; try { using (var progressReport = new ProgressReport(Store, ServerPackage)) { progressReport.ProgressChanged += (action, progress) => { Dispatcher.InvokeAsync(() => { UpdateProgress(action, progress); }).Forget(); }; - progressReport.UpdateProgress(ProgressAction.Delete, -1); - CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName); - await Store.UninstallPackage(LocalPackage, progressReport); - CurrentProcessStatus = null; + progressReport.UpdateProgress(ProgressAction.Install, -1); + await Store.InstallPackage(ServerPackage.Id, ServerPackage.Version, ServerPackage.TargetFrameworks, progressReport); + downloadCompleted = true; } + + AfterDownload(); } catch (Exception e) { + // Rollback: try to delete the broken package (i.e. if it is installed with NuGet but had a failure during Install scripts) + try + { + var localPackage = Store.FindLocalPackage(ServerPackage.Id, ServerPackage.Version); + if (localPackage is not null) + { + await Store.UninstallPackage(localPackage, null); + } + } + catch + { + // Note: quite a bad state: rollback (uninstall) failed + // we don't display the message to not confuse the user even more with an intermediate uninstall error message before the install error message + } + if (displayErrorMessage) { - var message = $"{UninstallErrorMessage}{e.FormatSummary(true)}"; + var message = $@"**{InstallErrorMessage}** +### Log +``` +{Launcher.LogMessages} +``` + +### Exception +``` +{e.FormatSummary(false).TrimEnd(Environment.NewLine.ToCharArray())} +```"; await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); return; } @@ -309,13 +260,58 @@ private async Task DeleteInternal(bool displayErrorMessage) await UpdateVersionsFromStore(); IsProcessing = false; } + }); + } + + protected async Task Delete(bool displayErrorMessage, bool askConfirmation) + { + bool proceed = !askConfirmation; + if (askConfirmation) + { + var message = string.Format(Strings.ConfirmUninstall, FullName); + var confirmResult = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.YesNo); + proceed = confirmResult == MessageBoxResult.Yes; } + if (proceed) + { + await Task.Run(() => DeleteInternal(displayErrorMessage)); + } + } - private void UpdateStatusInternal() + private async Task DeleteInternal(bool displayErrorMessage) + { + IsProcessing = true; + Debug.Assert(LocalPackage is not null); + try + { + using var progressReport = new ProgressReport(Store, ServerPackage); + progressReport.ProgressChanged += (action, progress) => { Dispatcher.InvokeAsync(() => { UpdateProgress(action, progress); }).Forget(); }; + progressReport.UpdateProgress(ProgressAction.Delete, -1); + CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName); + await Store.UninstallPackage(LocalPackage, progressReport); + CurrentProcessStatus = null; + } + catch (Exception e) + { + if (displayErrorMessage) + { + var message = $"{UninstallErrorMessage}{e.FormatSummary(true)}"; + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + throw; + } + finally { - CanBeDownloaded = (LocalPackage == null && ServerPackage != null) || (LocalPackage != null && ServerPackage != null && LocalPackage.Version < ServerPackage.Version); - CanDelete = LocalPackage != null; - DownloadCommand.IsEnabled = CanBeDownloaded; + await UpdateVersionsFromStore(); + IsProcessing = false; } } + + private void UpdateStatusInternal() + { + CanBeDownloaded = (LocalPackage is null && ServerPackage is not null) || (LocalPackage is not null && ServerPackage is not null && LocalPackage.Version < ServerPackage.Version); + CanDelete = LocalPackage is not null; + DownloadCommand.IsEnabled = CanBeDownloaded; + } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs index 0d4670c987..7302eb1611 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/RecentProjectViewModel.cs @@ -1,9 +1,7 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; + using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; using Stride.Core.Assets; using Stride.Core.Extensions; using Stride.Core.IO; @@ -11,123 +9,192 @@ using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Resources; -using Stride.LauncherApp.Services; +using Stride.Launcher.Assets.Localization; +using Stride.Launcher.Services; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed class RecentProjectViewModel : DispatcherViewModel { - internal class RecentProjectViewModel : DispatcherViewModel - { - private readonly UFile fullPath; - private string strideVersionName; - private Version strideVersion; + private readonly UFile fullPath; + private string strideVersionName; + private Version? strideVersion; - internal RecentProjectViewModel(LauncherViewModel launcher, UFile path) - : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) - { - Name = path.GetFileNameWithoutExtension(); - Launcher = launcher; - fullPath = path; - StrideVersionName = Strings.ReportDiscovering; - OpenCommand = new AnonymousTaskCommand(ServiceProvider, () => OpenWith(null)) { IsEnabled = false }; - OpenWithCommand = new AnonymousTaskCommand<StrideVersionViewModel>(ServiceProvider, OpenWith); - ExploreCommand = new AnonymousCommand(ServiceProvider, Explore); - RemoveCommand = new AnonymousCommand(ServiceProvider, Remove); - CompatibleVersions = new ObservableList<StrideVersionViewModel>(); - DiscoverStrideVersion(); - } + internal RecentProjectViewModel(MainViewModel launcher, UFile path) + : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) + { + Name = path.GetFileNameWithoutExtension(); + Launcher = launcher; + fullPath = path; + strideVersionName = Strings.ReportDiscovering; + OpenCommand = new AnonymousTaskCommand(ServiceProvider, () => OpenWith(null)) { IsEnabled = false }; + OpenWithCommand = new AnonymousTaskCommand<StrideVersionViewModel>(ServiceProvider, OpenWith); + ExploreCommand = new AnonymousCommand(ServiceProvider, Explore); + RemoveCommand = new AnonymousCommand(ServiceProvider, Remove); + CompatibleVersions = []; + DiscoverStrideVersion(); + } - public string Name { get; private set; } + public string Name { get; private set; } - public string FullPath => fullPath.ToOSPath(); + public string FullPath => fullPath.ToOSPath(); - public string StrideVersionName { get { return strideVersionName; } private set { SetValue(ref strideVersionName, value); } } + public string StrideVersionName { get { return strideVersionName; } private set { SetValue(ref strideVersionName, value); } } - public Version StrideVersion { get { return strideVersion; } private set { SetValue(ref strideVersion, value); } } + public Version? StrideVersion { get { return strideVersion; } private set { SetValue(ref strideVersion, value); } } - public LauncherViewModel Launcher { get; } + public MainViewModel Launcher { get; } - public ObservableList<StrideVersionViewModel> CompatibleVersions { get; private set; } + public ObservableList<StrideVersionViewModel> CompatibleVersions { get; private set; } - public ICommandBase ExploreCommand { get; } + public ICommandBase ExploreCommand { get; } - public ICommandBase OpenCommand { get; } + public ICommandBase OpenCommand { get; } - public ICommandBase OpenWithCommand { get; } + public ICommandBase OpenWithCommand { get; } - public ICommandBase RemoveCommand { get; } + public ICommandBase RemoveCommand { get; } - private void DiscoverStrideVersion() + private void DiscoverStrideVersion() + { + Task.Run(async () => { - Task.Run(async () => - { - var packageVersion = await PackageSessionHelper.GetPackageVersion(fullPath); - StrideVersion = packageVersion != null ? new Version(packageVersion.Version.Major, packageVersion.Version.Minor) : null; - StrideVersionName = StrideVersion?.ToString(); + var packageVersion = await PackageSessionHelper.GetPackageVersion(fullPath); + StrideVersion = packageVersion is not null ? new Version(packageVersion.Version.Major, packageVersion.Version.Minor) : null; + StrideVersionName = StrideVersion?.ToString(); - Dispatcher.Invoke(() => OpenCommand.IsEnabled = StrideVersionName != null); - }); - } + Dispatcher.Invoke(() => OpenCommand.IsEnabled = StrideVersionName is not null); + }); + } - private void Explore() + private void Explore() + { + // FullPath already resolves to the OS-native string path (see FullPath property above). + if (!File.Exists(FullPath)) { - var startInfo = new ProcessStartInfo("explorer.exe", $"/select,{fullPath.ToOSPath()}") { UseShellExecute = true }; - var explorer = new Process { StartInfo = startInfo }; - explorer.Start(); + return; } - private void Remove() + try { - //Remove files that's was deleted or upgraded by stride versions <= 3.0 - if (string.IsNullOrEmpty(StrideVersionName) || string.Compare(StrideVersionName, "3.0", StringComparison.Ordinal) <= 0) + if (OperatingSystem.IsWindows()) { - //Get all installed versions - var strideInstalledVersions = Launcher.StrideVersions.Where(x => x.CanDelete) - .Select(x => $"{x.Major}.{x.Minor}").ToList(); - - //If original version of files is not in list get and to add it. - if (!string.IsNullOrEmpty(StrideVersionName) && !strideInstalledVersions.Any(x => x.Equals(StrideVersionName))) - strideInstalledVersions.Add(StrideVersionName); - - foreach (var item in strideInstalledVersions) + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{FullPath}\"") { - GameStudioSettings.RemoveMostRecentlyUsed(fullPath, item); - } + UseShellExecute = true, + }); + } + else if (OperatingSystem.IsMacOS()) + { + Process.Start(new ProcessStartInfo("open", $"-R \"{FullPath}\"") + { + UseShellExecute = false, + }); } - else + else // Linux and any other Unix { - GameStudioSettings.RemoveMostRecentlyUsed(fullPath, StrideVersionName); + if (!TryRevealFileDBus(FullPath)) + { + var parent = Path.GetDirectoryName(FullPath); + if (parent is not null) + { + Process.Start(new ProcessStartInfo("xdg-open", parent) + { + UseShellExecute = false, + }); + } + } } } + catch + { + // File-manager failures are not actionable for the user — silently ignore. + } + } - private async Task OpenWith(StrideVersionViewModel version) + private static bool TryRevealFileDBus(string path) + { + try { - string message; - version = version ?? Launcher.StrideVersions.FirstOrDefault(x => new Version(x.Major, x.Minor) == StrideVersion); - if (version == null) + var uri = new Uri(path).AbsoluteUri; // "file:///…" with correct percent-encoding + + var psi = new ProcessStartInfo("dbus-send", string.Join(' ', + "--session", + "--type=method_call", + "--dest=org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + $"array:string:\"{uri}\"", + "string:\"\"")) { - message = string.Format(Strings.ErrorDoNotFindVersion, StrideVersion); - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); - return; - } - if (version.IsProcessing) + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using var process = Process.Start(psi); + if (process is null) return false; + + process.WaitForExit(2000); // 2s ceiling; healthy DBus round-trips are sub-10ms. + return process.HasExited && process.ExitCode == 0; + } + catch + { + return false; + } + } + + private void Remove() + { + //Remove files that's was deleted or upgraded by stride versions <= 3.0 + if (string.IsNullOrEmpty(StrideVersionName) || string.Compare(StrideVersionName, "3.0", StringComparison.Ordinal) <= 0) + { + //Get all installed versions + var strideInstalledVersions = Launcher.StrideVersions.Where(x => x.CanDelete) + .Select(x => $"{x.Major}.{x.Minor}").ToList(); + + //If original version of files is not in list get and to add it. + if (!string.IsNullOrEmpty(StrideVersionName) && !strideInstalledVersions.Any(x => x.Equals(StrideVersionName))) + strideInstalledVersions.Add(StrideVersionName); + + foreach (var item in strideInstalledVersions) { - message = string.Format(Strings.ErrorVersionBeingUpdated, StrideVersion); - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); - return; + GameStudioSettings.RemoveMostRecentlyUsed(fullPath, item); } - if (!version.CanDelete) + } + else + { + GameStudioSettings.RemoveMostRecentlyUsed(fullPath, StrideVersionName); + } + } + + private async Task OpenWith(StrideVersionViewModel? version) + { + string message; + version ??= Launcher.StrideVersions.FirstOrDefault(x => new Version(x.Major, x.Minor) == StrideVersion); + if (version is null) + { + message = string.Format(Strings.ErrorDoNotFindVersion, StrideVersion); + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + if (version.IsProcessing) + { + message = string.Format(Strings.ErrorVersionBeingUpdated, StrideVersion); + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + if (!version.CanDelete) + { + message = string.Format(Strings.ErrorVersionNotInstalled, StrideVersion); + var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.YesNoCancel, MessageBoxImage.Information); + if (result == MessageBoxResult.Yes) { - message = string.Format(Strings.ErrorVersionNotInstalled, StrideVersion); - var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.YesNoCancel, MessageBoxImage.Information); - if (result == MessageBoxResult.Yes) - { - version.DownloadCommand.Execute(); - } - return; + version.DownloadCommand.Execute(); } - Launcher.ActiveVersion = version; - Launcher.StartStudio($"\"{FullPath}\""); + return; } + Launcher.ActiveVersion = version; + Launcher.StartStudio($"\"{FullPath}\"").Forget(); } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/ReleaseNotesViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/ReleaseNotesViewModel.cs index d397e506b3..fc4d1156ef 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/ReleaseNotesViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/ReleaseNotesViewModel.cs @@ -1,94 +1,116 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.IO; -using System.Net.Http; + using System.Text.RegularExpressions; -using Stride.Core.Annotations; using Stride.Core.Extensions; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.ViewModels; -namespace Stride.LauncherApp.ViewModels +namespace Stride.Launcher.ViewModels; + +/// <summary> +/// This class represents the release notes of a given version. +/// </summary> +public sealed partial class ReleaseNotesViewModel : DispatcherViewModel { - /// <summary> - /// This class represents the release notes of a given version. - /// </summary> - internal class ReleaseNotesViewModel : DispatcherViewModel - { - private static readonly HttpClient httpClient = new(); + private static readonly HttpClient httpClient = new(); - private readonly LauncherViewModel launcher; - private bool isActive; - private string markdownContent; - private bool isLoading = true; - private bool isLoaded; - private bool isUnavailable; + private readonly MainViewModel launcher; + private bool isActive; + private string? markdownContent; + private bool isLoading = true; + private bool isLoaded; + private bool isUnavailable; - private const string RootUrl = "https://doc.stride3d.net"; - private const string ReleaseNotesFileName = "ReleaseNotes.md"; - private string baseUrl; + private const string RootUrl = "https://doc.stride3d.net"; + private const string ReleaseNotesFileName = "ReleaseNotes.md"; + private readonly string baseUrl; - internal ReleaseNotesViewModel([NotNull] LauncherViewModel launcher, [NotNull] string version) - : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) - { - if (version == null) throw new ArgumentNullException(nameof(version)); - this.launcher = launcher; + internal ReleaseNotesViewModel(MainViewModel launcher, string version) + : base(launcher.SafeArgument(nameof(launcher)).ServiceProvider) + { + ArgumentNullException.ThrowIfNull(launcher); + ArgumentNullException.ThrowIfNull(version); - Version = version; - baseUrl = $"{RootUrl}/{Version}/ReleaseNotes/"; + this.launcher = launcher; + Version = version; + baseUrl = $"{RootUrl}/{Version}/ReleaseNotes/"; #if DEBUG - if (Environment.CommandLine.ToLowerInvariant().Contains("/previewreleasenotes")) + if (Environment.CommandLine.ToLowerInvariant().Contains("/previewreleasenotes")) + { + var launcherPath = AppDomain.CurrentDomain.BaseDirectory; + var mdPath = Path.Combine(launcherPath, @"..\..\..\..\..\doc\"); + if (File.Exists($"{mdPath}{ReleaseNotesFileName}")) { - var launcherPath = AppDomain.CurrentDomain.BaseDirectory; - var mdPath = Path.Combine(launcherPath, @"..\..\..\..\..\doc\"); - if (File.Exists($"{mdPath}{ReleaseNotesFileName}")) - { - baseUrl = $"file:///{mdPath.Replace("\\", "/")}"; - } + baseUrl = $"file:///{mdPath.Replace("\\", "/")}"; } + } #endif - ToggleCommand = new AnonymousCommand(ServiceProvider, Toggle); - } + ToggleCommand = new AnonymousCommand(ServiceProvider, Toggle); + } - public string BaseUrl { get { return baseUrl; } } + public string BaseUrl { get { return baseUrl; } } - public string Version { get; } + public string Version { get; } - public string MarkdownContent { get { return markdownContent; } private set { SetValue(ref markdownContent, value); } } + public string? MarkdownContent { get { return markdownContent; } private set { SetValue(ref markdownContent, value); } } - public bool IsActive { get { return isActive; } private set { SetValue(ref isActive, value); } } + public bool IsActive { get { return isActive; } private set { SetValue(ref isActive, value); } } - public bool IsLoading { get { return isLoading; } set { SetValue(ref isLoading, value); } } + public bool IsLoading { get { return isLoading; } set { SetValue(ref isLoading, value); } } - public bool IsLoaded { get { return isLoaded; } set { SetValue(ref isLoaded, value); } } + public bool IsLoaded { get { return isLoaded; } set { SetValue(ref isLoaded, value); } } - public bool IsUnavailable { get { return isUnavailable; } set { SetValue(ref isUnavailable, value); } } + public bool IsUnavailable { get { return isUnavailable; } set { SetValue(ref isUnavailable, value); } } - public ICommandBase ToggleCommand { get; private set; } + public ICommandBase ToggleCommand { get; private set; } + + public async void FetchReleaseNotes() + { + string releaseNotesMarkdown; - public async void FetchReleaseNotes() + try { - string releaseNotesMarkdown; + using var response = await httpClient.GetAsync($"{BaseUrl}{ReleaseNotesFileName}"); + response.EnsureSuccessStatusCode(); + releaseNotesMarkdown = await response.Content.ReadAsStringAsync(); + } + catch (Exception) + { + IsLoading = false; + IsUnavailable = true; + return; + } - try - { - using var response = await httpClient.GetAsync($"{BaseUrl}{ReleaseNotesFileName}"); - response.EnsureSuccessStatusCode(); - releaseNotesMarkdown = await response.Content.ReadAsStringAsync(); - } - catch (Exception) - { - IsLoading = false; - IsUnavailable = true; - return; - } + if (releaseNotesMarkdown is not null) + { + // parse video tag + var videoRegex = GetVideoRegex(); + MarkdownContent = videoRegex.Replace(releaseNotesMarkdown, "![]($2)\r\n\r\n[_Click to watch the video_]($4)"); + IsLoading = false; + IsLoaded = true; + } + else + { + IsLoading = false; + IsUnavailable = true; + } + } - if (releaseNotesMarkdown != null) - { - // parse video tag - var videoRegex = new Regex(@" + public void Show() + { + IsActive = true; + launcher.ActiveReleaseNotes = this; + } + + private void Toggle() + { + IsActive = launcher.ActiveReleaseNotes != this || !IsActive; + launcher.ActiveReleaseNotes = this; + } + + [GeneratedRegex(@" <video [^>]*? # any valid HTML characters poster # poster attribute @@ -105,29 +127,6 @@ public async void FetchReleaseNotes() ([^'"" >] +?) # url of video \3 # matching quote [^>] *?>\s* - </video>", - RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); - MarkdownContent = videoRegex.Replace(releaseNotesMarkdown, "![]($2)\r\n\r\n[_Click to watch the video_]($4)"); - IsLoading = false; - IsLoaded = true; - } - else - { - IsLoading = false; - IsUnavailable = true; - } - } - - public void Show() - { - IsActive = true; - launcher.ActiveReleaseNotes = this; - } - - private void Toggle() - { - IsActive = launcher.ActiveReleaseNotes != this || !IsActive; - launcher.ActiveReleaseNotes = this; - } - } + </video>", RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace)] + private static partial Regex GetVideoRegex(); } diff --git a/sources/launcher/Stride.Launcher/ViewModels/StrideDevVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/StrideDevVersionViewModel.cs index 2fd5eb24c3..450ba93f50 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/StrideDevVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/StrideDevVersionViewModel.cs @@ -1,79 +1,78 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Threading.Tasks; + using Stride.Core.Annotations; using Stride.Core.IO; using Stride.Core.Packages; -namespace Stride.LauncherApp.ViewModels +namespace Stride.Launcher.ViewModels; + +/// <summary> +/// An implementation of the <see cref="StrideVersionViewModel"/> that represents a non-official version locally built. +/// </summary> +public sealed class StrideDevVersionViewModel : StrideVersionViewModel { - /// <summary> - /// An implementation of the <see cref="StrideVersionViewModel"/> that represents a non-official version locally built. - /// </summary> - internal class StrideDevVersionViewModel : StrideVersionViewModel + private readonly UDirectory path; + private static int devMinorCounter = int.MaxValue; + private readonly NugetLocalPackage localPackage; + private readonly bool isDevRedirect; + + internal StrideDevVersionViewModel(MainViewModel launcher, NugetStore store, [CanBeNull] NugetLocalPackage localPackage, UDirectory path, bool isDevRedirect) + : base(launcher, store, localPackage, localPackage.Id, int.MaxValue, devMinorCounter--) + { + this.path = path; + this.localPackage = localPackage; + this.isDevRedirect = isDevRedirect; + DownloadCommand.IsEnabled = false; + // Update initial status (IsVisible will be set to true) + UpdateStatus(); + } + + /// <inheritdoc/> + public override string Name => "Local " + path.MakeRelative(path.GetParent()); + + /// <inheritdoc/> + public override string DisplayName => localPackage is not null ? $"{PackageSimpleName} {localPackage.Version} (local)" : base.DisplayName; + + /// <inheritdoc/> + public override string FullName => localPackage?.Version.ToString() ?? path.MakeRelative(path.GetParent()); + + /// <inheritdoc/> + public override bool CanBeDownloaded => false; + + // TODO: a distinction between CanDelete and IsInstalled? + /// <inheritdoc/> + public override bool CanDelete => isDevRedirect; + + /// <inheritdoc/> + public override string InstallPath => path.ToOSPath(); + + + // This property is not used because a dev version cannot be downloaded. + /// <inheritdoc/> + protected override string InstallErrorMessage => string.Empty; + + // This property is not used because a dev version cannot be downloaded. + /// <inheritdoc/> + protected override string UninstallErrorMessage => string.Empty; + + /// <inheritdoc/> + protected override Task UpdateVersionsFromStore() + { + return Launcher.RetrieveLocalStrideVersions(); + } + + /// <inheritdoc/> + protected override void UpdateStatus() + { + base.UpdateStatus(); + // A dev version is always local and cannot be downloaded + DownloadCommand.IsEnabled = false; + } + + /// <inheritdoc/> + protected override void UpdateInstallStatus() { - private readonly UDirectory path; - private static int devMinorCounter = int.MaxValue; - private NugetLocalPackage localPackage; - private bool isDevRedirect; - - internal StrideDevVersionViewModel(LauncherViewModel launcher, NugetStore store, [CanBeNull] NugetLocalPackage localPackage, UDirectory path, bool isDevRedirect) - : base(launcher, store, localPackage, localPackage.Id, int.MaxValue, devMinorCounter--) - { - this.path = path; - this.localPackage = localPackage; - this.isDevRedirect = isDevRedirect; - DownloadCommand.IsEnabled = false; - // Update initial status (IsVisible will be set to true) - UpdateStatus(); - } - - /// <inheritdoc/> - public override string Name => "Local " + path.MakeRelative(path.GetParent()); - - /// <inheritdoc/> - public override string DisplayName => localPackage != null ? $"{PackageSimpleName} {localPackage.Version} (local)" : base.DisplayName; - - /// <inheritdoc/> - public override string FullName => localPackage?.Version.ToString() ?? path.MakeRelative(path.GetParent()); - - /// <inheritdoc/> - public override bool CanBeDownloaded => false; - - // TODO: a distinction between CanDelete and IsInstalled? - /// <inheritdoc/> - public override bool CanDelete => isDevRedirect; - - /// <inheritdoc/> - public override string InstallPath => path.ToOSPath(); - - - // This property is not used because a dev verison cannot be downloaded. - /// <inheritdoc/> - protected override string InstallErrorMessage => null; - - // This property is not used because a dev verison cannot be downloaded. - /// <inheritdoc/> - protected override string UninstallErrorMessage => null; - - /// <inheritdoc/> - protected override Task UpdateVersionsFromStore() - { - return Launcher.RetrieveLocalStrideVersions(); - } - - /// <inheritdoc/> - protected override void UpdateStatus() - { - base.UpdateStatus(); - // A dev version is always local and cannot be downloaded - DownloadCommand.IsEnabled = false; - } - - /// <inheritdoc/> - protected override void UpdateInstallStatus() - { - // A dev version cannot be installed - } + // A dev version cannot be installed } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/StrideStoreAlternateVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/StrideStoreAlternateVersionViewModel.cs index a9791442eb..d81b2fddfd 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/StrideStoreAlternateVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/StrideStoreAlternateVersionViewModel.cs @@ -1,67 +1,65 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + using Stride.Core; -using Stride.Core.Annotations; using Stride.Core.Packages; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.ViewModels; -namespace Stride.LauncherApp.ViewModels +namespace Stride.Launcher.ViewModels; + +public sealed class StrideStoreAlternateVersionViewModel : DispatcherViewModel { - internal sealed class StrideStoreAlternateVersionViewModel : DispatcherViewModel - { - private StrideStoreVersionViewModel strideVersion; - internal NugetServerPackage ServerPackage; - internal NugetLocalPackage LocalPackage; + internal NugetServerPackage ServerPackage; + internal NugetLocalPackage LocalPackage; - public StrideStoreAlternateVersionViewModel([NotNull] StrideStoreVersionViewModel strideVersion) - : base(strideVersion.ServiceProvider) + public StrideStoreAlternateVersionViewModel(StrideStoreVersionViewModel strideVersion) + : base(strideVersion.ServiceProvider) + { + SetAsActiveCommand = new AnonymousCommand(ServiceProvider, () => { - this.strideVersion = strideVersion; - - SetAsActiveCommand = new AnonymousCommand(ServiceProvider, () => + strideVersion.UpdateLocalPackage(LocalPackage, null); + if (LocalPackage is null) { - strideVersion.UpdateLocalPackage(LocalPackage, null); - if (LocalPackage == null) - { - // If it's a non installed version, offer same version for serverPackage so that it offers to install this specific version - strideVersion.UpdateServerPackage(ServerPackage, null); - } - else - { - // Otherwise, offer latest version for update - strideVersion.UpdateServerPackage(strideVersion.LatestServerPackage, null); - } + // If it's a non installed version, offer same version for serverPackage so that it offers to install this specific version + strideVersion.UpdateServerPackage(ServerPackage, null); + } + else + { + // Otherwise, offer latest version for update + strideVersion.UpdateServerPackage(strideVersion.LatestServerPackage, null); + } - strideVersion.Launcher.ActiveVersion = strideVersion; - }); - } + strideVersion.Launcher.ActiveVersion = strideVersion; + }); + } - /// <summary> - /// Gets the command that will set the associated version as active. - /// </summary> - public CommandBase SetAsActiveCommand { get; } + /// <summary> + /// Gets the command that will set the associated version as active. + /// </summary> + public CommandBase SetAsActiveCommand { get; } - public string FullName + public string FullName + { + get { - get - { - return LocalPackage != null ? $"{LocalPackage.Id} {LocalPackage.Version} (installed)" : $"{ServerPackage.Id} {ServerPackage.Version}"; - } + return LocalPackage is not null ? $"{LocalPackage.Id} {LocalPackage.Version} (installed)" : $"{ServerPackage.Id} {ServerPackage.Version}"; } + } - public PackageVersion Version => LocalPackage?.Version ?? ServerPackage.Version; + public PackageVersion Version => LocalPackage?.Version ?? ServerPackage.Version; - internal void UpdateLocalPackage(NugetLocalPackage package) - { - OnPropertyChanging(nameof(FullName), nameof(Version)); - LocalPackage = package; - OnPropertyChanged(nameof(FullName), nameof(Version)); - } + internal void UpdateLocalPackage(NugetLocalPackage package) + { + OnPropertyChanging(nameof(FullName), nameof(Version)); + LocalPackage = package; + OnPropertyChanged(nameof(FullName), nameof(Version)); + } - internal void UpdateServerPackage(NugetServerPackage package) - { - OnPropertyChanging(nameof(FullName), nameof(Version)); - ServerPackage = package; - OnPropertyChanged(nameof(FullName), nameof(Version)); - } + internal void UpdateServerPackage(NugetServerPackage package) + { + OnPropertyChanging(nameof(FullName), nameof(Version)); + ServerPackage = package; + OnPropertyChanged(nameof(FullName), nameof(Version)); } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/StrideStoreVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/StrideStoreVersionViewModel.cs index 1a25e4866f..0e7bdbc5e5 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/StrideStoreVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/StrideStoreVersionViewModel.cs @@ -1,299 +1,294 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections.Generic; + using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using Stride.Core; using Stride.Core.Extensions; using Stride.Core.Packages; using Stride.Core.Presentation.Collections; using Stride.Core.Presentation.Services; -using Stride.LauncherApp.Resources; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +/// <summary> +/// An implementation of the <see cref="StrideVersionViewModel"/> that represents an official release coming from the store. +/// </summary> +public sealed class StrideStoreVersionViewModel : StrideVersionViewModel { + public const string PrerequisitesInstaller = @"Bin\Prerequisites\install-prerequisites.exe"; + + internal NugetServerPackage LatestServerPackage; + private ReleaseNotesViewModel releaseNotes; + /// <summary> - /// An implementation of the <see cref="StrideVersionViewModel"/> that represents an official release coming from the store. + /// Initializes a new instance of the <see cref="StrideStoreVersionViewModel"/> /// </summary> - internal sealed class StrideStoreVersionViewModel : StrideVersionViewModel + /// <param name="launcher"></param> + /// <param name="store"></param> + /// <param name="localPackage"></param> + /// <param name="major"></param> + /// <param name="minor"></param> + internal StrideStoreVersionViewModel(MainViewModel launcher, NugetStore store, NugetLocalPackage? localPackage, string packageId, int major, int minor) + : base(launcher, store, localPackage, packageId, major, minor) { - public const string PrerequisitesInstaller = @"Bin\Prerequisites\install-prerequisites.exe"; - - internal NugetServerPackage LatestServerPackage; - private ReleaseNotesViewModel releaseNotes; - - /// <summary> - /// Initializes a new instance of the <see cref="StrideStoreVersionViewModel"/> - /// </summary> - /// <param name="launcher"></param> - /// <param name="store"></param> - /// <param name="localPackage"></param> - /// <param name="major"></param> - /// <param name="minor"></param> - internal StrideStoreVersionViewModel(LauncherViewModel launcher, NugetStore store, NugetLocalPackage localPackage, string packageId, int major, int minor) - : base(launcher, store, localPackage, packageId, major, minor) - { - FetchReleaseNotes(); - FetchDocumentation(); - } + FetchReleaseNotes(); + FetchDocumentation(); + } - /// <summary> - /// Checks whether the latest available package is from a remote repository (i.e. NuGet). - /// </summary> - public bool IsLatestPackageRemote + /// <summary> + /// Checks whether the latest available package is from a remote repository (i.e. NuGet). + /// </summary> + public bool IsLatestPackageRemote + { + get { - get - { - return (LatestServerPackage?.Source != null) && Uri.IsWellFormedUriString(LatestServerPackage.Source, UriKind.Absolute); - } + return (LatestServerPackage?.Source is not null) && Uri.IsWellFormedUriString(LatestServerPackage.Source, UriKind.Absolute); } + } - /// <summary> - /// Checks whether the latest available package is from a local repository (i.e. disk). - /// </summary> - public bool IsLatestPackageLocal + /// <summary> + /// Checks whether the latest available package is from a local repository (i.e. disk). + /// </summary> + public bool IsLatestPackageLocal + { + get { - get - { - return (LatestServerPackage?.Source != null) && (Directory.Exists(LatestServerPackage.Source)); - } + return (LatestServerPackage?.Source is not null) && (Directory.Exists(LatestServerPackage.Source)); } + } - /// <summary> - /// Gets the full name of this version, including revision number and special revision string. - /// </summary> - /// <remarks>If this version is installed, it will use the name of the installed version. Otherwise, it will use the name of the latest version available on the server.</remarks> - public override string FullName + /// <summary> + /// Gets the full name of this version, including revision number and special revision string. + /// </summary> + /// <remarks>If this version is installed, it will use the name of the installed version. Otherwise, it will use the name of the latest version available on the server.</remarks> + public override string FullName + { + get { - get - { - var result = Version?.ToString() ?? "Unknown"; - //if (ServerPackage != null) - // result += $" ({ServerPackage.Source})"; - return result; - } + var result = Version?.ToString() ?? "Unknown"; + //if (ServerPackage is not null) + // result += $" ({ServerPackage.Source})"; + return result; } + } + + /// <summary> + /// Gets the full name of this version on the server. + /// </summary> + public string ServerVersionFullName => ServerPackage?.Version?.ToString() ?? ""; - /// <summary> - /// Gets the full name of this version on the server. - /// </summary> - public string ServerVersionFullName => ServerPackage?.Version?.ToString() ?? ""; - - public ObservableList<StrideStoreAlternateVersionViewModel> AlternateVersions { get; } = new ObservableList<StrideStoreAlternateVersionViewModel>(); - - /// <summary> - /// Gets the release notes associated to this version. - /// </summary> - public ReleaseNotesViewModel ReleaseNotes { get { return releaseNotes; } private set { SetValue(ref releaseNotes, value); } } - - /// <summary> - /// Gets the collection of <see cref="DocumentationPageViewModel"/> associated with this version. - /// </summary> - public ObservableList<DocumentationPageViewModel> DocumentationPages { get; } = new ObservableList<DocumentationPageViewModel>(); - - /// <summary> - /// Gets the full version of the local package if it exists, or the server package. - /// </summary> - /// <value>The version.</value> - public PackageVersion Version => LocalPackage != null ? LocalPackage.Version : ServerPackage?.Version; - - /// <summary> - /// Updates the local package of this version. - /// </summary> - /// <param name="package">The local package corresponding to this version.</param> - internal void UpdateLocalPackage(NugetLocalPackage package, IEnumerable<NugetLocalPackage> alternateVersions) + public ObservableList<StrideStoreAlternateVersionViewModel> AlternateVersions { get; } = []; + + /// <summary> + /// Gets the release notes associated to this version. + /// </summary> + public ReleaseNotesViewModel ReleaseNotes { get { return releaseNotes; } private set { SetValue(ref releaseNotes, value); } } + + /// <summary> + /// Gets the collection of <see cref="DocumentationPageViewModel"/> associated with this version. + /// </summary> + public ObservableList<DocumentationPageViewModel> DocumentationPages { get; } = []; + + /// <summary> + /// Gets the full version of the local package if it exists, or the server package. + /// </summary> + /// <value>The version.</value> + public PackageVersion Version => LocalPackage is not null ? LocalPackage.Version : ServerPackage?.Version; + + /// <summary> + /// Updates the local package of this version. + /// </summary> + /// <param name="package">The local package corresponding to this version.</param> + internal void UpdateLocalPackage(NugetLocalPackage? package, IEnumerable<NugetLocalPackage>? alternateVersions) + { + OnPropertyChanging(nameof(FullName), nameof(Version)); + LocalPackage = package; + OnPropertyChanged(nameof(FullName), nameof(Version)); + Dispatcher.Invoke(UpdateStatus); + if (alternateVersions is not null) { - OnPropertyChanging(nameof(FullName), nameof(Version)); - LocalPackage = package; - OnPropertyChanged(nameof(FullName), nameof(Version)); - Dispatcher.Invoke(UpdateStatus); - if (alternateVersions != null) + Dispatcher.Invoke(() => { - Dispatcher.Invoke(() => + UpdateAlternateVersions(alternateVersions, (alternateVersionViewModel, alternateVersion) => { - UpdateAlternateVersions(alternateVersions, (alternateVersionViewModel, alternateVersion) => - { - if (alternateVersion == null && alternateVersionViewModel.ServerPackage == null) - AlternateVersions.Remove(alternateVersionViewModel); - else - alternateVersionViewModel.UpdateLocalPackage(alternateVersion); - }); + if (alternateVersion is null && alternateVersionViewModel.ServerPackage is null) + AlternateVersions.Remove(alternateVersionViewModel); + else + alternateVersionViewModel.UpdateLocalPackage(alternateVersion); }); - } - Dispatcher.Invoke(() => UpdateFrameworks()); + }); } + Dispatcher.Invoke(UpdateFrameworks); + } - /// <summary> - /// Updates the server package of this version. - /// </summary> - /// <param name="package">The server package corresponding to this version.</param> - internal void UpdateServerPackage(NugetServerPackage package, IEnumerable<NugetServerPackage> alternateVersions) - { - OnPropertyChanging(nameof(FullName), nameof(Version)); - ServerPackage = package; - OnPropertyChanged(nameof(FullName), nameof(Version)); - - // Always keep track of highest version - if (ServerPackage != null && (LatestServerPackage == null || LatestServerPackage.Version < ServerPackage.Version)) - { - OnPropertyChanging(nameof(IsLatestPackageRemote), nameof(IsLatestPackageLocal)); - LatestServerPackage = ServerPackage; - OnPropertyChanged(nameof(IsLatestPackageRemote), nameof(IsLatestPackageLocal)); - } + /// <summary> + /// Updates the server package of this version. + /// </summary> + /// <param name="package">The server package corresponding to this version.</param> + internal void UpdateServerPackage(NugetServerPackage package, IEnumerable<NugetServerPackage> alternateVersions) + { + OnPropertyChanging(nameof(FullName), nameof(Version)); + ServerPackage = package; + OnPropertyChanged(nameof(FullName), nameof(Version)); - Dispatcher.Invoke(UpdateStatus); - if (alternateVersions != null) - { - Dispatcher.Invoke(() => - UpdateAlternateVersions(alternateVersions, (alternateVersionViewModel, alternateVersion) => - { - if (alternateVersion == null && alternateVersionViewModel.LocalPackage == null) - AlternateVersions.Remove(alternateVersionViewModel); - else - alternateVersionViewModel.UpdateServerPackage(alternateVersion); - })); - } + // Always keep track of highest version + if (ServerPackage is not null && (LatestServerPackage is null || LatestServerPackage.Version < ServerPackage.Version)) + { + OnPropertyChanging(nameof(IsLatestPackageRemote), nameof(IsLatestPackageLocal)); + LatestServerPackage = ServerPackage; + OnPropertyChanged(nameof(IsLatestPackageRemote), nameof(IsLatestPackageLocal)); } - private void UpdateAlternateVersions<T>(IEnumerable<T> alternateVersions, Action<StrideStoreAlternateVersionViewModel, T> updateAction) where T : NugetPackage + Dispatcher.Invoke(UpdateStatus); + if (alternateVersions is not null) { - var updatedViewModels = new HashSet<StrideStoreAlternateVersionViewModel>(); - foreach (var alternateVersion in alternateVersions) - { - - int index = AlternateVersions.IndexOf(x => x.Version == alternateVersion.Version); - StrideStoreAlternateVersionViewModel alternateVersionViewModel; - if (index < 0) - { - // If not, add it - alternateVersionViewModel = new StrideStoreAlternateVersionViewModel(this); - AlternateVersions.Add(alternateVersionViewModel); - } - else + Dispatcher.Invoke(() => + UpdateAlternateVersions(alternateVersions, (alternateVersionViewModel, alternateVersion) => { - // If yes, update it and remove it from the list of old version - alternateVersionViewModel = AlternateVersions[index]; - } - - updateAction(alternateVersionViewModel, alternateVersion); - updatedViewModels.Add(alternateVersionViewModel); - } - - // Update versions that are not installed locally anymore - foreach (var alternateVersionViewModel in AlternateVersions.Where(x => !updatedViewModels.Contains(x)).ToList()) - { - updateAction(alternateVersionViewModel, null); - } + if (alternateVersion is null && alternateVersionViewModel.LocalPackage is null) + AlternateVersions.Remove(alternateVersionViewModel); + else + alternateVersionViewModel.UpdateServerPackage(alternateVersion); + })); } + } - internal async Task RunPrerequisitesInstaller() + private void UpdateAlternateVersions<T>(IEnumerable<T> alternateVersions, Action<StrideStoreAlternateVersionViewModel, T> updateAction) where T : NugetPackage + { + var updatedViewModels = new HashSet<StrideStoreAlternateVersionViewModel>(); + foreach (var alternateVersion in alternateVersions) { - // Only used for older packages - if (ServerPackage.Version.Version >= new Version(1, 11, 2, 0)) + + int index = AlternateVersions.IndexOf(x => x.Version == alternateVersion.Version); + StrideStoreAlternateVersionViewModel alternateVersionViewModel; + if (index < 0) { - return; + // If not, add it + alternateVersionViewModel = new(this); + AlternateVersions.Add(alternateVersionViewModel); } - - // Run prerequisites installer (if it exists) - var prerequisitesInstaller = PrerequisitesInstaller; - var packagePath = Store.GetInstalledPath(ServerPackage.Id, ServerPackage.Version); - var prerequisitesInstallerPath = Path.Combine(packagePath, prerequisitesInstaller); - if (File.Exists(prerequisitesInstallerPath)) + else { - CurrentProcessStatus = Strings.ReportInstallingPrerequisites; - var prerequisitesInstalled = false; - while (!prerequisitesInstalled) - { - try - { - var prerequisitesInstallerProcess = Process.Start(prerequisitesInstallerPath); - prerequisitesInstallerProcess?.WaitForExit(); - prerequisitesInstalled = true; - } - catch - { - // We'll enter this if UAC has been declined, but also if it timed out (which is a frequent case - // if you don't stay in front of your computer during the installation. - var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync("The installation of prerequisites has been canceled by user or failed to run. Do you want to run it again?", MessageBoxButton.YesNoCancel, MessageBoxImage.Question); - if (result != MessageBoxResult.Yes) - break; - } - } + // If yes, update it and remove it from the list of old version + alternateVersionViewModel = AlternateVersions[index]; } - } - /// <inheritdoc/> - protected override string InstallErrorMessage => string.Format(Strings.ErrorInstallingVersion, ServerVersionFullName); - - /// <inheritdoc/> - protected override string UninstallErrorMessage => string.Format(Strings.ErrorUninstallingVersion, FullName); + updateAction(alternateVersionViewModel, alternateVersion); + updatedViewModels.Add(alternateVersionViewModel); + } - /// <inheritdoc/> - protected override Task UpdateVersionsFromStore() + // Update versions that are not installed locally anymore + foreach (var alternateVersionViewModel in AlternateVersions.Where(x => !updatedViewModels.Contains(x)).ToList()) { - return Launcher.RetrieveAllStrideVersions(); + updateAction(alternateVersionViewModel, null); } + } - /// <inheritdoc/> - protected override void UpdateStatus() + internal async Task RunPrerequisitesInstaller() + { + // Only used for older packages + if (ServerPackage.Version.Version >= new Version(1, 11, 2, 0)) { - base.UpdateStatus(); - OnPropertyChanging(nameof(ServerVersionFullName)); - OnPropertyChanged(nameof(ServerVersionFullName)); + return; } - /// <inheritdoc/> - protected override void UpdateInstallStatus() + // Run prerequisites installer (if it exists) + var prerequisitesInstaller = PrerequisitesInstaller; + var packagePath = Store.GetInstalledPath(ServerPackage.Id, ServerPackage.Version); + var prerequisitesInstallerPath = Path.Combine(packagePath, prerequisitesInstaller); + if (File.Exists(prerequisitesInstallerPath)) { - switch (CurrentProgressAction) + CurrentProcessStatus = Strings.ReportInstallingPrerequisites; + var prerequisitesInstalled = false; + while (!prerequisitesInstalled) { - case ProgressAction.Download: - CurrentProcessStatus = string.Format(Strings.ReportDownloadingVersion, ServerVersionFullName, CurrentProgress); - break; - case ProgressAction.Install: - CurrentProcessStatus = string.Format(Strings.ReportInstallingVersion, ServerVersionFullName, CurrentProgress); - break; - case ProgressAction.Delete: - CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName, CurrentProgress); - break; + try + { + var prerequisitesInstallerProcess = Process.Start(prerequisitesInstallerPath); + await prerequisitesInstallerProcess?.WaitForExitAsync(); + prerequisitesInstalled = true; + } + catch + { + // We'll enter this if UAC has been declined, but also if it timed out (which is a frequent case + // if you don't stay in front of your computer during the installation. + var result = await ServiceProvider.Get<IDialogService>().MessageBoxAsync("The installation of prerequisites has been canceled by user or failed to run. Do you want to run it again?", MessageBoxButton.YesNoCancel, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) + break; + } } } + } - /// <inheritdoc/> - protected override void BeforeDownload() - { - base.BeforeDownload(); - ReleaseNotes.Show(); - } + /// <inheritdoc/> + protected override string InstallErrorMessage => string.Format(Strings.ErrorInstallingVersion, ServerVersionFullName); - /// <inheritdoc/> - protected override void AfterDownload() - { - base.AfterDownload(); - RunPrerequisitesInstaller().Forget(); + /// <inheritdoc/> + protected override string UninstallErrorMessage => string.Format(Strings.ErrorUninstallingVersion, FullName); - Launcher.ActiveVersion = this; - } + /// <inheritdoc/> + protected override Task UpdateVersionsFromStore() + { + return Launcher.RetrieveAllStrideVersions(); + } + + /// <inheritdoc/> + protected override void UpdateStatus() + { + base.UpdateStatus(); + OnPropertyChanging(nameof(ServerVersionFullName)); + OnPropertyChanged(nameof(ServerVersionFullName)); + } - /// <summary> - /// Fetches the documentation pages corresponding to this version from the server. - /// </summary> - internal async void FetchDocumentation() + /// <inheritdoc/> + protected override void UpdateInstallStatus() + { + switch (CurrentProgressAction) { - var pages = await DocumentationPageViewModel.FetchGettingStartedPages(ServiceProvider, $"{Major}.{Minor}"); - Dispatcher.Invoke(() => { DocumentationPages.Clear(); DocumentationPages.AddRange(pages); }); + case ProgressAction.Download: + CurrentProcessStatus = string.Format(Strings.ReportDownloadingVersion, ServerVersionFullName, CurrentProgress); + break; + case ProgressAction.Install: + CurrentProcessStatus = string.Format(Strings.ReportInstallingVersion, ServerVersionFullName, CurrentProgress); + break; + case ProgressAction.Delete: + CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName, CurrentProgress); + break; } + } - internal void FetchReleaseNotes() + /// <inheritdoc/> + protected override void BeforeDownload() + { + base.BeforeDownload(); + ReleaseNotes.Show(); + } + + /// <inheritdoc/> + protected override void AfterDownload() + { + base.AfterDownload(); + RunPrerequisitesInstaller().Forget(); + + Launcher.ActiveVersion = this; + } + + /// <summary> + /// Fetches the documentation pages corresponding to this version from the server. + /// </summary> + internal async void FetchDocumentation() + { + var pages = await DocumentationPageViewModel.FetchGettingStartedPages(ServiceProvider, $"{Major}.{Minor}"); + await Dispatcher.InvokeAsync(() => { DocumentationPages.Clear(); DocumentationPages.AddRange(pages); }); + } + + internal void FetchReleaseNotes() + { + Dispatcher.Invoke(() => { - Dispatcher.Invoke(() => - { - ReleaseNotes = new ReleaseNotesViewModel(Launcher, $"{Major}.{Minor}"); - ReleaseNotes.FetchReleaseNotes(); - }); - } + ReleaseNotes = new(Launcher, $"{Major}.{Minor}"); + ReleaseNotes.FetchReleaseNotes(); + }); } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs index 562a9d1eb5..659a9b4703 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/StrideVersionViewModel.cs @@ -1,223 +1,236 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.IO; -using System.Linq; + using NuGet.Frameworks; using Stride.Core.Packages; using Stride.Core.Presentation.Collections; using Stride.Core.Presentation.Commands; -using Stride.LauncherApp.Services; -namespace Stride.LauncherApp.ViewModels +namespace Stride.Launcher.ViewModels; + +/// <summary> +/// An implementation of the <see cref="PackageVersionViewModel"/> that represents a major version of Stride. +/// </summary> +public abstract class StrideVersionViewModel : PackageVersionViewModel, IComparable<StrideVersionViewModel>, IComparable<Tuple<int, int>> { - /// <summary> - /// An implementation of the <see cref="PackageVersionViewModel"/> that represents a major version of Stride. - /// </summary> - internal abstract class StrideVersionViewModel : PackageVersionViewModel, IComparable<StrideVersionViewModel>, IComparable<Tuple<int, int>> + private bool isVisible; + private bool canStart; + private string? selectedFramework; + + internal StrideVersionViewModel(MainViewModel launcher, NugetStore store, NugetLocalPackage? localPackage, string packageId, int major, int minor) + : base(launcher, store, localPackage) { - public const string MainExecutables = @"lib\net472\Stride.GameStudio.exe,lib\net472\Xenko.GameStudio.exe,Bin\Windows\Xenko.GameStudio.exe,Bin\Windows-Direct3D11\Xenko.GameStudio.exe"; - private const string StrideGameStudioExe = "Stride.GameStudio.exe"; - private const string XenkoGameStudioExe = "Xenko.GameStudio.exe"; + PackageSimpleName = packageId + .Replace(".GameStudio", string.Empty) + .Replace(".Avalonia.Desktop", string.Empty); + Major = major; + Minor = minor; + SetAsActiveCommand = new AnonymousCommand(ServiceProvider, () => launcher.ActiveVersion = this); + // Update status if the user changes whether to display beta versions. + launcher.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(MainViewModel.ShowBetaVersions)) UpdateStatus(); }; + } - private bool isVisible; - private bool canStart; - private string selectedFramework; + protected static string[] GetExecutableNames() + { + return OperatingSystem.IsWindows() + ? [ + $"{GameStudioNames.StrideAvalonia}.exe", + $"{GameStudioNames.Stride}.exe", + $"{GameStudioNames.Xenko}.exe", + ] + : [$"{GameStudioNames.StrideAvalonia}.dll"]; + } - internal StrideVersionViewModel(LauncherViewModel launcher, NugetStore store, NugetLocalPackage localPackage, string packageId, int major, int minor) - : base(launcher, store, localPackage) - { - PackageSimpleName = packageId.Replace(".GameStudio", string.Empty); - Major = major; - Minor = minor; - SetAsActiveCommand = new AnonymousCommand(ServiceProvider, () => launcher.ActiveVersion = this); - // Update status if the user changes whether to display beta versions. - launcher.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(LauncherViewModel.ShowBetaVersions)) UpdateStatus(); }; - } + protected void UpdateFrameworks() + { + Frameworks.Clear(); + if (LocalPackage is null || InstallPath is null) + return; - protected void UpdateFrameworks() + foreach (var toplevelFolder in new[] { "tools", "lib" }) { - Frameworks.Clear(); - if (LocalPackage == null || InstallPath == null) - { - return; - } - foreach (var toplevelFolder in new[] { "tools", "lib" }) + var libDirectory = Path.Combine(InstallPath, toplevelFolder); + if (!Directory.Exists(libDirectory)) + continue; + + foreach (var frameworkPath in Directory.EnumerateDirectories(libDirectory)) { - var libDirectory = Path.Combine(InstallPath, toplevelFolder); - if (Directory.Exists(libDirectory)) + foreach (var gameStudioExecutable in GetExecutableNames()) { - foreach (var frameworkPath in Directory.EnumerateDirectories(libDirectory)) + if (File.Exists(Path.Combine(frameworkPath, gameStudioExecutable))) { - if (File.Exists(Path.Combine(frameworkPath, Major >= 4 ? StrideGameStudioExe : XenkoGameStudioExe))) - { - Frameworks.Add(new DirectoryInfo(frameworkPath).Name); - } + Frameworks.Add(new DirectoryInfo(frameworkPath).Name); } } } + } + UpdateSelectedFramework(); + } - if (Frameworks.Count > 0) + internal void UpdateSelectedFramework() + { + if (Frameworks.Count > 0) + { + try { - try + // If preferred framework exists in our list, select it + var preferredFramework = Launcher.Settings.PreferredFramework; + if (Frameworks.Contains(preferredFramework)) { - // If preferred framework exists in our list, select it - var preferredFramework = LauncherSettings.PreferredFramework; - if (Frameworks.Contains(preferredFramework)) - SelectedFramework = preferredFramework; - else - { - // Otherwise, try to find a framework of the same kind (.NET Core or .NET Framework) - var nugetFramework = NuGetFramework.ParseFolder(preferredFramework); - SelectedFramework = - Frameworks.FirstOrDefault(x => NuGetFramework.ParseFolder(preferredFramework).Framework == nugetFramework.Framework) - ?? Frameworks.First(); // otherwise fallback to first choice - } + SelectedFramework = preferredFramework; } - catch + else { - SelectedFramework = Frameworks.First(); + // Otherwise, try to find a framework of the same kind (.NET Core or .NET Framework) + var nugetFramework = NuGetFramework.ParseFolder(preferredFramework); + SelectedFramework = + Frameworks.FirstOrDefault(x => NuGetFramework.ParseFolder(preferredFramework).Framework == nugetFramework.Framework) + ?? Frameworks.First(); // otherwise fallback to first choice } } + catch + { + SelectedFramework = Frameworks.First(); + } } + } - public string PackageSimpleName { get; } - - /// <summary> - /// Gets the major number of this version. - /// </summary> - public int Major { get; } - - /// <summary> - /// Gets the minor number of this version. - /// </summary> - public int Minor { get; } - - /// <summary> - /// Gets the name of this version. - /// </summary> - public override string Name => GetName(PackageSimpleName, Major, Minor); - - /// <summary> - /// Gets the display name of this version. - /// </summary> - public virtual string DisplayName => GetName(PackageSimpleName, Major, Minor, true); - - /// <summary> - /// Gets the command that will set the associated package as active. - /// </summary> - public CommandBase SetAsActiveCommand { get; } - - /// <summary> - /// Gets whether this version is a beta version. - /// </summary> - public bool IsBeta => IsBetaVersion(Major, Minor); - - /// <summary> - /// Gets whether this version should be displayed. - /// </summary> - public bool IsVisible { get { return isVisible; } private set { SetValue(ref isVisible, value); } } - - /// <summary> - /// Gets whether this version can be started. - /// </summary> - public bool CanStart { get { return canStart; } private set { SetValue(ref canStart, value); } } - - public ObservableList<string> Frameworks { get; } = new ObservableList<string>(); - - public string SelectedFramework { get { return selectedFramework; } set { SetValue(ref selectedFramework, value); } } - - /// <summary> - /// Builds a string that represents the given version numbers. - /// </summary> - /// <param name="majorVersion">The major version number.</param> - /// <param name="minorVersion">The minor version number.</param> - /// <param name="isDisplayName">Indicates whether the name to compute is a display name, or a string token used to build urls.</param> - /// <returns>A string representing the given version numbers.</returns> - public static string GetName(string packageSimpleName, int majorVersion, int minorVersion, bool isDisplayName = false) - { - if (isDisplayName && IsBetaVersion(majorVersion, minorVersion)) - return $"{packageSimpleName} {majorVersion}.{minorVersion}-beta"; + public string PackageSimpleName { get; } - return $"{packageSimpleName} {majorVersion}.{minorVersion}"; - } + /// <summary> + /// Gets the major number of this version. + /// </summary> + public int Major { get; } - /// <summary> - /// Indicates if the given version corresponds to a beta version. - /// </summary> - /// <param name="majorVersion">The major number of the version.</param> - /// <param name="minorVersion">The minor nimber of the version.</param> - /// <returns>True if the given version is a beta, false otherwise.</returns> - public static bool IsBetaVersion(int majorVersion, int minorVersion) - { - return majorVersion < 3; - } + /// <summary> + /// Gets the minor number of this version. + /// </summary> + public int Minor { get; } - /// <inheritdoc/> - protected override void UpdateStatus() - { - base.UpdateStatus(); - // It is visible if it's installed, or if it's not a beta, or if user want to see be available betas - IsVisible = Launcher.ShowBetaVersions || !IsBeta || CanDelete; - SetAsActiveCommand.IsEnabled = CanDelete; - DeleteCommand.IsEnabled = CanDelete; - CanStart = CanDelete; - - if (Launcher.ActiveVersion == this) - Launcher.StartStudioCommand.IsEnabled = CanStart; - } + /// <summary> + /// Gets the name of this version. + /// </summary> + public override string Name => GetName(PackageSimpleName, Major, Minor); - /// <summary> - /// Name of main executable of current store. - /// </summary> - /// <returns>Name of the executable.</returns> - public string GetMainExecutables() - { - return MainExecutables; - } + /// <summary> + /// Gets the display name of this version. + /// </summary> + public virtual string DisplayName => GetName(PackageSimpleName, Major, Minor, true); + + /// <summary> + /// Gets the command that will set the associated package as active. + /// </summary> + public CommandBase SetAsActiveCommand { get; } - /// <summary> - /// Locate the main executable from a given package installation path. It throws exceptions if not found. - /// </summary> - /// <param name="packagePath">The package installation path.</param> - /// <returns>The main executable.</returns> - public string LocateMainExecutable() + /// <summary> + /// Gets whether this version is a beta version. + /// </summary> + public bool IsBeta => IsBetaVersion(Major, Minor); + + /// <summary> + /// Gets whether this version should be displayed. + /// </summary> + public bool IsVisible { get { return isVisible; } private set { SetValue(ref isVisible, value); } } + + /// <summary> + /// Gets whether this version can be started. + /// </summary> + public bool CanStart { get { return canStart; } private set { SetValue(ref canStart, value); } } + + public ObservableList<string> Frameworks { get; } = []; + + public string? SelectedFramework { get { return selectedFramework; } set { SetValue(ref selectedFramework, value); } } + + /// <summary> + /// Builds a string that represents the given version numbers. + /// </summary> + /// <param name="majorVersion">The major version number.</param> + /// <param name="minorVersion">The minor version number.</param> + /// <param name="isDisplayName">Indicates whether the name to compute is a display name, or a string token used to build urls.</param> + /// <returns>A string representing the given version numbers.</returns> + public static string GetName(string packageSimpleName, int majorVersion, int minorVersion, bool isDisplayName = false) + { + if (isDisplayName && IsBetaVersion(majorVersion, minorVersion)) + return $"{packageSimpleName} {majorVersion}.{minorVersion}-beta"; + + return $"{packageSimpleName} {majorVersion}.{minorVersion}"; + } + + /// <summary> + /// Indicates if the given version corresponds to a beta version. + /// </summary> + /// <param name="majorVersion">The major number of the version.</param> + /// <param name="minorVersion">The minor nimber of the version.</param> + /// <returns>True if the given version is a beta, false otherwise.</returns> + public static bool IsBetaVersion(int majorVersion, int minorVersion) + { + return majorVersion < 3; + } + + /// <inheritdoc/> + protected override void UpdateStatus() + { + base.UpdateStatus(); + // It is visible if it's installed, or if it's not a beta, or if user want to see be available betas + IsVisible = Launcher.ShowBetaVersions || !IsBeta || CanDelete; + SetAsActiveCommand.IsEnabled = CanDelete; + DeleteCommand.IsEnabled = CanDelete; + CanStart = CanDelete; + + if (Launcher.ActiveVersion == this) + Launcher.StartStudioCommand.IsEnabled = CanStart; + } + + /// <summary> + /// Locate the main executable from a given package installation path. It throws exceptions if not found. + /// </summary> + /// <returns>The main executable.</returns> + public string? LocateMainExecutable() + { + if (InstallPath is null) + return null; + + // First, try to use the selected framework + if (SelectedFramework is not null) { - // First, try to use the selected framework - if (SelectedFramework != null) + foreach (var toplevelFolder in new[] { "tools", "lib" }) { - foreach (var toplevelFolder in new[] { "tools", "lib" }) + var gameStudioDirectory = Path.Combine(InstallPath, toplevelFolder, SelectedFramework); + foreach (var gameStudioExecutable in GetExecutableNames()) { - var gameStudioDirectory = Path.Combine(InstallPath, toplevelFolder, SelectedFramework); - foreach (var gameStudioExecutable in new[] { "Stride.GameStudio.exe", "Xenko.GameStudio.exe" }) - { - var gameStudioPath = Path.Combine(gameStudioDirectory, gameStudioExecutable); - if (File.Exists(gameStudioPath)) - return gameStudioPath; - } + var gameStudioPath = Path.Combine(gameStudioDirectory, gameStudioExecutable); + if (File.Exists(gameStudioPath)) + return gameStudioPath; } } - - // Otherwise, old-style fallback - var mainExecutableList = GetMainExecutables(); - var fullExePath = mainExecutableList.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => Path.Combine(InstallPath, x)).FirstOrDefault(File.Exists); - if (fullExePath == null) - throw new InvalidOperationException("Unable to locate the executable for the selected version"); - - return fullExePath; } + // Otherwise, old-style fallback + return GetMainExecutables().Select(x => Path.Combine(InstallPath, x)).FirstOrDefault(File.Exists) + ?? throw new InvalidOperationException("Unable to locate the executable for the selected version"); - public int CompareTo(StrideVersionViewModel other) + static IEnumerable<string> GetMainExecutables() { - var r = Major.CompareTo(other.Major); - return r != 0 ? -r : -Minor.CompareTo(other.Minor); + // some old paths used in previous versions + if (OperatingSystem.IsWindows()) + { + yield return @$"lib\net472\{GameStudioNames.Stride}.exe"; + yield return @$"lib\net472\{GameStudioNames.Xenko}.exe"; + yield return @$"Bin\Windows\{GameStudioNames.Xenko}.exe"; + yield return @$"Bin\Windows-Direct3D11\{GameStudioNames.Xenko}.exe"; + } } + } - public int CompareTo(Tuple<int, int> other) - { - var r = Major.CompareTo(other.Item1); - return r != 0 ? -r : -Minor.CompareTo(other.Item2); - } + public int CompareTo(StrideVersionViewModel? other) + { + var r = Major.CompareTo(other?.Major); + return r != 0 ? -r : -Minor.CompareTo(other?.Minor); + } + + public int CompareTo(Tuple<int, int>? other) + { + var r = Major.CompareTo(other?.Item1); + return r != 0 ? -r : -Minor.CompareTo(other?.Item2); } } diff --git a/sources/launcher/Stride.Launcher/ViewModels/VsixVersionViewModel.cs b/sources/launcher/Stride.Launcher/ViewModels/VsixVersionViewModel.cs index 852cb7b53f..2bdedf21c3 100644 --- a/sources/launcher/Stride.Launcher/ViewModels/VsixVersionViewModel.cs +++ b/sources/launcher/Stride.Launcher/ViewModels/VsixVersionViewModel.cs @@ -1,150 +1,146 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; + using Stride.Core.Extensions; using Stride.Core.Packages; using Stride.Core.Presentation.Commands; using Stride.Core.Presentation.Services; -using Stride.LauncherApp.Resources; +using Stride.Launcher.Assets.Localization; + +namespace Stride.Launcher.ViewModels; -namespace Stride.LauncherApp.ViewModels +public sealed class VsixVersionViewModel : PackageVersionViewModel { - internal sealed class VsixVersionViewModel : PackageVersionViewModel + private readonly string packageId; + private bool isLatestVersionInstalled; + private string status; + private readonly NugetStore.VsixSupportedVsVersion vsixSupportedVsVersion; + + internal VsixVersionViewModel(MainViewModel launcher, NugetStore store, string packageId, NugetStore.VsixSupportedVsVersion vsixSupportedVsVersion) + : base(launcher, store, null) { - private readonly string packageId; - private bool isLatestVersionInstalled; - private string status; - private readonly NugetStore.VsixSupportedVsVersion vsixSupportedVsVersion; + this.packageId = packageId; + this.vsixSupportedVsVersion = vsixSupportedVsVersion; + status = FormatStatus(Strings.ReportChecking); + ExecuteActionCommand = new AnonymousTaskCommand(ServiceProvider, ExecuteAction) { IsEnabled = false }; + } - internal VsixVersionViewModel(LauncherViewModel launcher, NugetStore store, string packageId, NugetStore.VsixSupportedVsVersion vsixSupportedVsVersion) - : base(launcher, store, null) - { - this.packageId = packageId; - this.vsixSupportedVsVersion = vsixSupportedVsVersion; - status = FormatStatus(Strings.ReportChecking); - ExecuteActionCommand = new AnonymousTaskCommand(ServiceProvider, ExecuteAction) { IsEnabled = false }; - } + /// <inheritdoc/> + public override string Name => Strings.VisualStudioExtension; - /// <inheritdoc/> - public override string Name => Strings.VisualStudioExtension; + /// <inheritdoc/> + public override string FullName => Name; - /// <inheritdoc/> - public override string FullName => Name; + /// <summary> + /// Gets whether the latest version of the VSIX package is installed. + /// </summary> + /// <remarks>This property is updated by <see cref="UpdateFromStore"/> and requires the latest Nuget package to be in the local store.</remarks> + public bool IsLatestVersionInstalled { get { return isLatestVersionInstalled; } private set { SetValue(ref isLatestVersionInstalled, value); } } - /// <summary> - /// Gets whether the latest version of the VSIX package is installed. - /// </summary> - /// <remarks>This property is updated by <see cref="UpdateFromStore"/> and requires the latest Nuget package to be in the local store.</remarks> - public bool IsLatestVersionInstalled { get { return isLatestVersionInstalled; } private set { SetValue(ref isLatestVersionInstalled, value); } } + /// <summary> + /// Gets the current status of the VSIX package. + /// </summary> + public string Status { get { return status; } private set { SetValue(ref status, value); } } - /// <summary> - /// Gets the current status of the VSIX package. - /// </summary> - public string Status { get { return status; } private set { SetValue(ref status, value); } } + /// <summary> + /// Gets a command that will download the latest version of the VSIX and install it on all compatible versions of Visual Studio. + /// </summary> + public ICommandBase ExecuteActionCommand { get; } - /// <summary> - /// Gets a command that will download the latest version of the VSIX and install it on all compatible versions of Visual Studio. - /// </summary> - public ICommandBase ExecuteActionCommand { get; } + /// <inheritdoc/> + protected override string InstallErrorMessage => Strings.ErrorInstallingVSIX; - /// <inheritdoc/> - protected override string InstallErrorMessage => Strings.ErrorInstallingVSIX; + /// <inheritdoc/> + protected override string UninstallErrorMessage => Strings.ErrorUninstallingVSIX; - /// <inheritdoc/> - protected override string UninstallErrorMessage => Strings.ErrorUninstallingVSIX; + public async Task UpdateFromStore() + { + Dispatcher.Invoke(() => Status = FormatStatus(Strings.ReportChecking)); + await UpdateVersionsFromStore(); + await Dispatcher.InvokeAsync(UpdateStatus); + } - public async Task UpdateFromStore() + /// <inheritdoc/> + protected override void UpdateStatus() + { + base.UpdateStatus(); + var newStatus = Strings.VSIXVerbReinstall; + if (CanBeDownloaded) { - Dispatcher.Invoke(() => Status = FormatStatus(Strings.ReportChecking)); - await UpdateVersionsFromStore(); - Dispatcher.Invoke(UpdateStatus); + newStatus = LocalPackage is null ? Strings.VSIXVerbInstall : Strings.VSIXVerbUpdate; + IsLatestVersionInstalled = false; } - /// <inheritdoc/> - protected override void UpdateStatus() - { - base.UpdateStatus(); - var newStatus = Strings.VSIXVerbReinstall; - if (CanBeDownloaded) - { - newStatus = LocalPackage == null ? Strings.VSIXVerbInstall : Strings.VSIXVerbUpdate; - IsLatestVersionInstalled = false; - } - - // Enable the control only if there is an eligible package for the VS extension. - ExecuteActionCommand.IsEnabled = (LocalPackage != null || ServerPackage != null); - Status = FormatStatus(newStatus); - } + // Enable the control only if there is an eligible package for the VS extension. + ExecuteActionCommand.IsEnabled = (LocalPackage is not null || ServerPackage is not null); + Status = FormatStatus(newStatus); + } - private string FormatStatus(string status) + private string FormatStatus(string status) + { + string vsixTarget = "Visual Studio {0} extension"; + switch (vsixSupportedVsVersion) { - string vsixTarget = "Visual Studio {0} extension"; - switch (vsixSupportedVsVersion) - { - case NugetStore.VsixSupportedVsVersion.VS2019: - vsixTarget = string.Format(vsixTarget, "2019"); - break; - case NugetStore.VsixSupportedVsVersion.VS2022AndNext: - vsixTarget = string.Format(vsixTarget, "2022+"); - break; - } - return $"{vsixTarget}: {status}"; + case NugetStore.VsixSupportedVsVersion.VS2019: + vsixTarget = string.Format(vsixTarget, "2019"); + break; + case NugetStore.VsixSupportedVsVersion.VS2022AndNext: + vsixTarget = string.Format(vsixTarget, "2022+"); + break; } + return $"{vsixTarget}: {status}"; + } - /// <inheritdoc/> - protected override void UpdateInstallStatus() + /// <inheritdoc/> + protected override void UpdateInstallStatus() + { + switch (CurrentProgressAction) { - switch (CurrentProgressAction) - { - case ProgressAction.Download: - CurrentProcessStatus = string.Format(Strings.ReportDownloadingVSIX, CurrentProgress); - break; - case ProgressAction.Install: - CurrentProcessStatus = string.Format(Strings.ReportInstallingVSIX, CurrentProgress); - break; - case ProgressAction.Delete: - CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName, CurrentProgress); - break; - } + case ProgressAction.Download: + CurrentProcessStatus = string.Format(Strings.ReportDownloadingVSIX, CurrentProgress); + break; + case ProgressAction.Install: + CurrentProcessStatus = string.Format(Strings.ReportInstallingVSIX, CurrentProgress); + break; + case ProgressAction.Delete: + CurrentProcessStatus = string.Format(Strings.ReportDeletingVersion, FullName, CurrentProgress); + break; } + } - /// <inheritdoc/> - protected override async Task UpdateVersionsFromStore() - { - var versionRange = Store.VsixVersionToStrideRelease[vsixSupportedVsVersion]; - var minVersion = versionRange.MinVersion; - var maxVersion = versionRange.MaxVersion; + /// <inheritdoc/> + protected override async Task UpdateVersionsFromStore() + { + var versionRange = Store.VsixVersionToStrideRelease[vsixSupportedVsVersion]; + var minVersion = versionRange.MinVersion; + var maxVersion = versionRange.MaxVersion; - LocalPackage = await Launcher.RunLockTask(() => Store.GetLocalPackages(packageId).Where(package => package.Version >= minVersion && package.Version < maxVersion).OrderByDescending(p => p.Version).FirstOrDefault()); - ServerPackage = await Launcher.RunLockTask(() => Store.FindSourcePackagesById(packageId, CancellationToken.None).Result.Where(package => package.Version >= minVersion && package.Version < maxVersion).OrderByDescending(p => p.Version).FirstOrDefault()); - } + LocalPackage = await Launcher.RunLockTask(() => Store.GetLocalPackages(packageId).Where(package => package.Version >= minVersion && package.Version < maxVersion).OrderByDescending(p => p.Version).FirstOrDefault()); + ServerPackage = await Launcher.RunLockTask(() => Store.FindSourcePackagesById(packageId, CancellationToken.None).Result.Where(package => package.Version >= minVersion && package.Version < maxVersion).OrderByDescending(p => p.Version).FirstOrDefault()); + } - public async Task ExecuteAction() + public async Task ExecuteAction() + { + await Task.Run(async () => { - await Task.Run(async () => + await Download(false); + + IsProcessing = true; + string checkingStatus = Strings.ReportChecking; + try { - await Download(false); - - IsProcessing = true; - string checkingStatus = Strings.ReportChecking; - try - { - CurrentProcessStatus = checkingStatus; - IsProcessing = false; - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.VSIXInstallSucessful, MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception e) - { - CurrentProcessStatus = checkingStatus; - IsProcessing = false; - var message = $"{Strings.ErrorInstallingVSIX}{e.FormatSummary(true)}"; - await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); - } - UpdateStatus(); - }); - } + CurrentProcessStatus = checkingStatus; + IsProcessing = false; + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(Strings.VSIXInstallSucessful, MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception e) + { + CurrentProcessStatus = checkingStatus; + IsProcessing = false; + var message = $"{Strings.ErrorInstallingVSIX}{e.FormatSummary(true)}"; + await ServiceProvider.Get<IDialogService>().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error); + } + UpdateStatus(); + }); } } diff --git a/sources/launcher/Stride.Launcher/Views/Announcement.axaml b/sources/launcher/Stride.Launcher/Views/Announcement.axaml new file mode 100644 index 0000000000..21e360e7fe --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/Announcement.axaml @@ -0,0 +1,22 @@ +<UserControl xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mv="using:MarkView.Avalonia" + xmlns:vm="using:Stride.Launcher.ViewModels" + mc:Ignorable="d" + x:Class="Stride.Launcher.Views.Announcement" + x:DataType="vm:AnnouncementViewModel"> + <Grid> + <DockPanel> + <DockPanel DockPanel.Dock="Bottom"> + <Button DockPanel.Dock="Right" + Content="OK" + Command="{Binding CloseAnnouncementCommand}" /> + <CheckBox IsChecked="{Binding DontShowAgain}" + Content="Don't show me this again" /> + </DockPanel> + <mv:MarkdownViewer Markdown="{Binding MarkdownAnnouncement}" /> + </DockPanel> + </Grid> +</UserControl> diff --git a/sources/launcher/Stride.Launcher/Views/Announcement.axaml.cs b/sources/launcher/Stride.Launcher/Views/Announcement.axaml.cs new file mode 100644 index 0000000000..209085644e --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/Announcement.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Controls; + +namespace Stride.Launcher.Views; + +public partial class Announcement : UserControl +{ + public Announcement() + { + InitializeComponent(); + } +} diff --git a/sources/launcher/Stride.Launcher/Views/Announcement.xaml b/sources/launcher/Stride.Launcher/Views/Announcement.xaml deleted file mode 100644 index 24eb182b9b..0000000000 --- a/sources/launcher/Stride.Launcher/Views/Announcement.xaml +++ /dev/null @@ -1,60 +0,0 @@ -<UserControl x:Class="Stride.LauncherApp.Views.Announcement" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:sskk="http://schemas.stride3d.net/xaml/presentation" - xmlns:s="clr-namespace:System;assembly=mscorlib" - mc:Ignorable="d" - d:DesignHeight="300" d:DesignWidth="300"> - <UserControl.Resources> - <s:Double x:Key="AnnouncementMargin">40</s:Double> - <Style x:Key="{x:Static sskk:XamlMarkdown.DocumentStyleKey}" - TargetType="{x:Type FlowDocument}" BasedOn="{StaticResource {x:Type FlowDocument}}"> - <Setter Property="FontSize" Value="20"/> - </Style> - </UserControl.Resources> - <Grid Background="Transparent" Tag="{sskk:Double 1}" IsEnabled="{Binding Validated, Converter={sskk:InvertBool}}"> - <Grid.RenderTransform> - <TranslateTransform X="{sskk:MultiBinding {Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Grid}, Converter={sskk:SumNum}, ConverterParameter={StaticResource AnnouncementMargin}}, - {Binding Tag, RelativeSource={RelativeSource AncestorType=Grid}}, Converter={sskk:MultiplyMultiConverter}}"/> - </Grid.RenderTransform> - <Grid.Style> - <Style TargetType="Grid"> - <Style.Triggers> - <!--<EventTrigger RoutedEvent="Grid.Loaded"> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="1" To="0" Duration="0:0:0.8" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </EventTrigger>--> - <Trigger Property="Grid.IsEnabled" Value="True"> - <Trigger.EnterActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="1" To="0" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.EnterActions> - <Trigger.ExitActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="0" To="-1" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.ExitActions> - </Trigger> - </Style.Triggers> - </Style> - </Grid.Style> - <DockPanel Margin="80" Background="{StaticResource ControlBackgroundBrush}"> - <DockPanel DockPanel.Dock="Bottom" Margin="20"> - <Button HorizontalAlignment="Right" Content="OK" Padding="24,8" Command="{Binding CloseAnnouncementCommand}" - DockPanel.Dock="Right"/> - <CheckBox IsChecked="{Binding DontShowAgain}" Content="Don't show me this again" VerticalAlignment="Bottom"/> - </DockPanel> - <sskk:MarkdownTextBlock x:Name="AnouncementPanel" Margin="10" Text="{Binding MarkdownAnnouncement, Mode=OneWay}" /> - </DockPanel> - </Grid> -</UserControl> diff --git a/sources/launcher/Stride.Launcher/Views/Announcement.xaml.cs b/sources/launcher/Stride.Launcher/Views/Announcement.xaml.cs deleted file mode 100644 index 72f866174e..0000000000 --- a/sources/launcher/Stride.Launcher/Views/Announcement.xaml.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System.Windows.Controls; - -namespace Stride.LauncherApp.Views -{ - /// <summary> - /// Interaction logic for Announcement.xaml - /// </summary> - public partial class Announcement : UserControl - { - public Announcement() - { - InitializeComponent(); - } - } -} diff --git a/sources/launcher/Stride.Launcher/Views/Commands.cs b/sources/launcher/Stride.Launcher/Views/Commands.cs deleted file mode 100644 index 494341688e..0000000000 --- a/sources/launcher/Stride.Launcher/Views/Commands.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Diagnostics; -using System.Windows; -using System.Windows.Threading; -using Stride.Core.Annotations; -using Stride.Core.Presentation.Commands; -using Stride.Core.Presentation.Extensions; -using Stride.Core.Presentation.View; -using Stride.Core.Presentation.ViewModels; - -namespace Stride.LauncherApp.Views -{ - public static class Commands - { - private static readonly Lazy<ICommandBase> LazyOpenHyperlinkCommand = new Lazy<ICommandBase>(OpenHyperlinkCommandFactory); - - public static ICommandBase OpenHyperlinkCommand => LazyOpenHyperlinkCommand.Value; - - [NotNull] - private static ICommandBase OpenHyperlinkCommandFactory() - { - // TODO: have a proper way to initialize the services (maybe at application startup) - var serviceProvider = new ViewModelServiceProvider(new[] { new DispatcherService(Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher) }); - return new AnonymousCommand<string>(serviceProvider, OpenHyperlink, CanOpenHyperlink); - } - - private static bool CanOpenHyperlink([CanBeNull] string url) - { - return !string.IsNullOrEmpty(url) && Uri.IsWellFormedUriString(url, UriKind.RelativeOrAbsolute); - } - - private static void OpenHyperlink([NotNull] string url) - { - // see https://support.microsoft.com/en-us/kb/305703 - try - { - // Make sure we open proper HTML pages - Process.Start(new ProcessStartInfo(url.ReplaceLast(".md", ".html")) { UseShellExecute = true }); - } - catch (System.ComponentModel.Win32Exception e) - { - if (e.ErrorCode == -2147467259) - MessageBox.Show(e.Message); - } - catch (Exception e) - { - MessageBox.Show(e.Message); - } - } - } -} diff --git a/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml b/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml deleted file mode 100644 index 3fe2ff3f03..0000000000 --- a/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml +++ /dev/null @@ -1,808 +0,0 @@ -<Window x:Class="Stride.LauncherApp.Views.LauncherWindow" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:l="clr-namespace:Stride.LauncherApp.Views" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="clr-namespace:Stride.LauncherApp.ViewModels" - xmlns:sskk="http://schemas.stride3d.net/xaml/presentation" - xmlns:i="http://schemas.microsoft.com/xaml/behaviors" - xmlns:services="clr-namespace:Stride.LauncherApp.Services" - xmlns:s="clr-namespace:System;assembly=mscorlib" - xmlns:r="clr-namespace:Stride.LauncherApp.Resources" - Title="{x:Static r:Strings.LauncherTitle}" - mc:Ignorable="d" d:DataContext="{d:DesignInstance vm:LauncherViewModel}" - WindowStartupLocation="CenterScreen" Style="{DynamicResource WindowChromeStyle}" - Height="768" Width="1024" FontFamily="Segoe UI" - Visibility="{Binding IsVisible, Converter={sskk:VisibleOrHidden}}"> - <Window.Resources> - <ResourceDictionary> - <!--BitmapImage x:Key="LauncherIcon" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/Logo.ico" /--> - <BitmapImage x:Key="EditorIcon" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/EditorIcon.png" /> - <BitmapImage x:Key="ImageSwitchVersion" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/switch-version.png"/> - <BitmapImage x:Key="ImageVisualStudio" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/visual-studio.png"/> - <BitmapImage x:Key="ImageProjects" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/recent-projects.png"/> - <BitmapImage x:Key="ImageGithub" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/github.png"/> - <BitmapImage x:Key="ImageIssues" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/issues.png"/> - <BitmapImage x:Key="ImageForums" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/chat-16.png"/> - <BitmapImage x:Key="ImageChat" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/discord.png"/> - <BitmapImage x:Key="ImageShowcase" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/showcase.png"/> - <BitmapImage x:Key="ImageRoadmap" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/roadmap.png"/> - <BitmapImage x:Key="ImageSurvey" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/survey.png"/> - <BitmapImage x:Key="ImageXTwitter" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/xtwitter_24.png"/> - <BitmapImage x:Key="ImageFacebook" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/facebook_24.png"/> - <BitmapImage x:Key="ImageReddit" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/reddit_24.png"/> - <BitmapImage x:Key="ImageOpenCollective" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/opencollective_24.png"/> - - <BitmapImage x:Key="ImageDownload" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/download-26-dark.png"/> - <BitmapImage x:Key="ImageUpdate" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/update.png"/> - <BitmapImage x:Key="ImageDelete" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/delete-26-dark.png"/> - <BitmapImage x:Key="ImageUpgrade" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/upgrade-16.png"/> - <BitmapImage x:Key="ImageReleaseNotes" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/note-26-dark.png"/> - <BitmapImage x:Key="ImageListVersions" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/list-26.png"/> - <BitmapImage x:Key="ImageBackground" UriSource="pack://application:,,,/Stride.Launcher;component/Resources/Robot.jpg"/> - - <s:Double x:Key="ReleaseNotesMargin">20</s:Double> - <Thickness x:Key="TileBorderThickness">12</Thickness> - <Thickness x:Key="TileListBorderThickness">0,0,0,20</Thickness> - <!-- 15 + 16 vertically --> - <Thickness x:Key="TabHeaderMargin">0,15,0,31</Thickness> - <SolidColorBrush x:Key="TileBorderBrush" Color="Transparent"/> - <SolidColorBrush x:Key="TileAlphaBackgroundBrush" Color="#C0434343"/> - <SolidColorBrush x:Key="EmphasisButtonBackground" Color="#900520"/> - <Color x:Key="EmphasisButtonHoverColor">#DA0830</Color> - - <Style TargetType="{x:Type l:StaysOpenContextMenu}" BasedOn="{StaticResource {x:Type ContextMenu}}"/> - - <DataTemplate DataType="{x:Type vm:StrideDevVersionViewModel}"> - <DockPanel Margin="0,2" Height="32" SnapsToDevicePixels="True" Background="{StaticResource TileAlphaBackgroundBrush}" - Visibility="{Binding IsVisible, Converter={sskk:VisibleOrCollapsed}, Mode=OneWay}"> - <StackPanel MinWidth="32" Orientation="Horizontal" DockPanel.Dock="Right" IsEnabled="{Binding IsProcessing, Converter={sskk:InvertBool}}"> - <!-- DELETE BUTTON --> - <Button Content="{sskk:Image {StaticResource ImageDelete}, 20, 20}" Width="32" Height="32" ToolTipService.IsEnabled="False" - ToolTip="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipUninstall}}" Margin="4,0,0,0" - Visibility="{Binding CanDelete, Converter={sskk:VisibleOrCollapsed}}" Command="{Binding DeleteCommand}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - </StackPanel> - <Grid> - <Button Command="{Binding SetAsActiveCommand}" x:Name="VersionButton" ToolTipService.IsEnabled="False" - ToolTip="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipSetActiveVersion}}" - Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}, ConverterParameter={sskk:False}}"> - <TextBlock Text="{Binding DisplayName, StringFormat={x:Static r:Strings.VersionButton}, Mode=OneWay}"/> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - </Grid> - </DockPanel> - </DataTemplate> - - <DataTemplate DataType="{x:Type vm:StrideStoreAlternateVersionViewModel}"> - <TextBlock Text="ASD" /> - </DataTemplate> - - <DataTemplate DataType="{x:Type vm:StrideStoreVersionViewModel}"> - <DockPanel Margin="0,2" Height="32" SnapsToDevicePixels="True" Background="{StaticResource TileAlphaBackgroundBrush}" - Visibility="{Binding IsVisible, Converter={sskk:VisibleOrCollapsed}, Mode=OneWay}"> - <StackPanel MinWidth="32" Orientation="Horizontal" DockPanel.Dock="Right" IsEnabled="{Binding IsProcessing, Converter={sskk:InvertBool}}"> - <!-- UPDATE BUTTON --> - <Button Width="32" Height="32" ToolTipService.IsEnabled="False" Margin="4,0,0,0" - ToolTip="{Binding ServerVersionFullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipUpdate}}" - Visibility="{sskk:MultiBinding {Binding CanBeDownloaded}, {Binding CanDelete}, - Converter={sskk:MultiChained {sskk:AndMultiConverter}, {sskk:VisibleOrCollapsed}}}" Command="{Binding DownloadCommand}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <Rectangle Width="20" Height="20" Fill="White"> - <Rectangle.OpacityMask> - <ImageBrush ImageSource="{StaticResource ImageUpdate}"/> - </Rectangle.OpacityMask> - </Rectangle> - </Button> - <!-- DOWNLOAD BUTTON --> - <Button Content="{sskk:Image {StaticResource ImageDownload}, 20, 20}" Width="32" Height="32" ToolTipService.IsEnabled="False" Margin="4,0,0,0" - ToolTip="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipDownload}}" - Visibility="{sskk:MultiBinding {Binding CanBeDownloaded}, {Binding CanDelete, Converter={sskk:InvertBool}}, - Converter={sskk:MultiChained {sskk:AndMultiConverter}, {sskk:VisibleOrCollapsed}}}" Command="{Binding DownloadCommand}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <!-- DELETE BUTTON --> - <Button Content="{sskk:Image {StaticResource ImageDelete}, 20, 20}" Width="32" Height="32" ToolTipService.IsEnabled="False" - ToolTip="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipUninstall}}" Margin="4,0,0,0" - Visibility="{Binding CanDelete, Converter={sskk:VisibleOrCollapsed}}" Command="{Binding DeleteCommand}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <!-- ALTERNATE VERSIONS --> - <ToggleButton x:Name="Toggle" Content="{sskk:Image {StaticResource ImageListVersions}, 20, 20}" Width="32" Height="32" - ToolTipService.IsEnabled="False" ToolTip="Install another version" Margin="4,0,0,0"/> - <Popup IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}" StaysOpen="False" AllowsTransparency="True"> - <Border Margin="6" Background="{StaticResource ControlBackgroundBrush}" - BorderBrush="{StaticResource NormalBrush}" BorderThickness="1"> - <Border.Effect> - <DropShadowEffect BlurRadius="5" Opacity="0.4"/> - </Border.Effect> - <Menu ItemsSource="{Binding AlternateVersions}"> - <Menu.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Vertical"/> - </ItemsPanelTemplate> - </Menu.ItemsPanel> - <Menu.ItemTemplate> - <DataTemplate> - <MenuItem Header="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipSetActiveVersion}}" - Command="{Binding SetAsActiveCommand}"> - <i:Interaction.Behaviors> - <sskk:OnEventSetPropertyBehavior EventName="Click" EventOwnerType="MenuItem" Target="{Binding ElementName=Toggle}" Property="ToggleButton.IsChecked" Value="{sskk:False}" /> - </i:Interaction.Behaviors> - </MenuItem> - </DataTemplate> - </Menu.ItemTemplate> - </Menu> - </Border> - </Popup> - </StackPanel> - <!-- RELEASE NOTES BUTTON --> - <Button Content="{sskk:Image {StaticResource ImageReleaseNotes}, 20, 20}" Width="32" Height="32" - ToolTipService.IsEnabled="False" ToolTip="Display release notes for this version" - Margin="0,0,4,0" Command="{Binding ReleaseNotes.ToggleCommand}" DockPanel.Dock="Left"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <Grid> - <ProgressBar x:Name="ProgressBar" Minimum="0" Maximum="100" Value="{Binding CurrentProgress, Mode=OneWay}" - BorderThickness="0" Background="{StaticResource ControlBackgroundBrush}" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}" - IsIndeterminate="{Binding CurrentProgress, Mode=OneWay, Converter={l:ProgressToIndeterminatedConverter}}"/> - <TextBlock Text="{Binding CurrentProcessStatus, Mode=OneWay}" Foreground="{StaticResource TextBrush}" HorizontalAlignment="Center" - VerticalAlignment="Center" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}"/> - <DockPanel> - <TextBlock Text="{Binding CurrentProcessStatus, Mode=OneWay}" HorizontalAlignment="Center" - VerticalAlignment="Center" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}"/> - <UIElement.Clip> - <RectangleGeometry> - <RectangleGeometry.Rect> - <MultiBinding Converter="{l:ProgressToRectConverter}"> - <Binding ElementName="ProgressBar" Path="ActualWidth"/> - <Binding ElementName="ProgressBar" Path="ActualHeight"/> - <Binding Path="CurrentProgress"/> - </MultiBinding> - </RectangleGeometry.Rect> - </RectangleGeometry> - </UIElement.Clip> - </DockPanel> - <Grid Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}, ConverterParameter={sskk:False}}"> - <Button Command="{Binding SetAsActiveCommand}" ToolTipService.IsEnabled="False" - Visibility="{Binding CanDelete, Converter={sskk:VisibleOrHidden}}" - ToolTip="{Binding FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipSetActiveVersion}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <TextBlock HorizontalAlignment="Left" Margin="16,0" IsHitTestVisible="False" Foreground="White" VerticalAlignment="Center"> - <Run Text="{Binding DisplayName, StringFormat={x:Static r:Strings.VersionButton}, Mode=OneWay}"/> - <!-- Careful here, there must not be any space or line-break between the next two Run tags! --> - <Run Text="{Binding CanDelete, Converter={sskk:Chained {sskk:InvertBool}, {sskk:BoolToParam}, Parameter2={x:Static r:Strings.VersionButtonUninstalled}}, - Mode=OneWay}"/><Run Text="{sskk:MultiBinding {Binding CanDelete}, {Binding CanBeDownloaded}, {Binding IsLatestPackageRemote}, Converter={sskk:MultiChained - {sskk:AndMultiConverter}, {sskk:BoolToParam}, Parameter1={x:Static r:Strings.VersionButtonUpdateAvailable}}, Mode=OneWay}"/><Run Text="{sskk:MultiBinding - {Binding CanDelete}, {Binding CanBeDownloaded}, {Binding IsLatestPackageLocal}, Converter={sskk:MultiChained {sskk:AndMultiConverter}, {sskk:BoolToParam}, - Parameter1={x:Static r:Strings.VersionButtonLocalUpdateAvailable}}, Mode=OneWay}"/> - </TextBlock> - </Grid> - </Grid> - </DockPanel> - </DataTemplate> - - <DataTemplate DataType="{x:Type vm:VsixVersionViewModel}"> - <Grid Height="32" SnapsToDevicePixels="True" Background="{StaticResource TileAlphaBackgroundBrush}"> - <ProgressBar x:Name="ProgressBar" Minimum="0" Maximum="100" Value="{Binding CurrentProgress, Mode=OneWay}" - BorderThickness="0" Background="{StaticResource ControlBackgroundBrush}" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}" - IsIndeterminate="{Binding CurrentProgress, Mode=OneWay, Converter={l:ProgressToIndeterminatedConverter}}"/> - <TextBlock Text="{Binding CurrentProcessStatus, Mode=OneWay}" Foreground="{StaticResource TextBrush}" HorizontalAlignment="Center" - VerticalAlignment="Center" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}"/> - <DockPanel> - <TextBlock Text="{Binding CurrentProcessStatus, Mode=OneWay}" HorizontalAlignment="Center" - VerticalAlignment="Center" Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}}"/> - <UIElement.Clip> - <RectangleGeometry> - <RectangleGeometry.Rect> - <MultiBinding Converter="{l:ProgressToRectConverter}"> - <Binding ElementName="ProgressBar" Path="ActualWidth"/> - <Binding ElementName="ProgressBar" Path="ActualHeight"/> - <Binding Path="CurrentProgress"/> - </MultiBinding> - </RectangleGeometry.Rect> - </RectangleGeometry> - </UIElement.Clip> - </DockPanel> - <Button Content="{Binding Status}" Command="{Binding ExecuteActionCommand}" ToolTipService.IsEnabled="False" - ToolTip="{Binding Status, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipVisualStudioExtension}}" - Visibility="{Binding IsProcessing, Converter={sskk:VisibleOrCollapsed}, ConverterParameter={sskk:False}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - </Grid> - </DataTemplate> - </ResourceDictionary> - </Window.Resources> - <Window.Background> - <ImageBrush ImageSource="{StaticResource ImageBackground}" Stretch="UniformToFill"/> - </Window.Background> - <Grid> - <DockPanel> - <StatusBar DockPanel.Dock="Bottom" Background="{StaticResource EmphasisButtonBackground}"> - <StatusBar.ItemsPanel> - <ItemsPanelTemplate> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="3*"/> - <ColumnDefinition Width="*"/> - <ColumnDefinition Width="Auto"/> - </Grid.ColumnDefinitions> - </Grid> - </ItemsPanelTemplate> - </StatusBar.ItemsPanel> - <StatusBarItem Grid.Column="0"> - <TextBlock Text="{Binding CurrentToolTip}" - Margin="8,4" HorizontalAlignment="Left" VerticalAlignment="Center" Foreground="White"/> - </StatusBarItem> - <!-- RECONNECT BUTTON --> - <StatusBarItem Grid.Column="1" HorizontalContentAlignment="Stretch" IsHitTestVisible="True" Focusable="True"> - <Button Style="{StaticResource TransparentButtonStyle}" - Command="{Binding ReconnectCommand}" - Cursor="Hand" - Margin="8,4" - HorizontalAlignment="Center" - VerticalAlignment="Center" - ToolTip="Click to reconnect..." - Visibility="{Binding IsOffline, Converter={sskk:VisibleOrHidden}}"> - <TextBlock FontWeight="ExtraBold" Foreground="Gold" Text="Offline" /> - </Button> - </StatusBarItem> - <StatusBarItem Grid.Column="2" HorizontalContentAlignment="Stretch"> - <TextBlock Text="{Binding StringFormat=Launcher v{0}, Source={x:Static services:SelfUpdater.Version}}" Margin="8,4" HorizontalAlignment="Right" - VerticalAlignment="Center" Foreground="White"/> - </StatusBarItem> - </StatusBar> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}"> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto"/> - <ColumnDefinition Width="*"/> - </Grid.ColumnDefinitions> - - <Grid.RowDefinitions> - <RowDefinition Height="*"/> - <RowDefinition Height="Auto"/> - </Grid.RowDefinitions> - - <!-- FIRST COLUMN --> - <DockPanel Grid.Row="0" Grid.Column="0" Width="400"> - <Border DockPanel.Dock="Bottom" BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" - Background="{StaticResource TileAlphaBackgroundBrush}"> - <DockPanel Margin="10"> - <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" DockPanel.Dock="Bottom" Margin="10"> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.XTwitter}" SnapsToDevicePixels="True" Margin="2" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}" - Content="{sskk:Image {StaticResource ImageXTwitter}, 24, 24, HighQuality}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Facebook}" SnapsToDevicePixels="True" Margin="2" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}" - Content="{sskk:Image {StaticResource ImageFacebook}, 24, 24, HighQuality}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Reddit}" SnapsToDevicePixels="True" Margin="2" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}" - Content="{sskk:Image {StaticResource ImageReddit}, 24, 24, HighQuality}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.OpenCollective}" SnapsToDevicePixels="True" Margin="2" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}" - Content="{sskk:Image {StaticResource ImageOpenCollective}, 24, 24, HighQuality}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <!--<Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="https://www.google.com/" SnapsToDevicePixels="True" Margin="2" - Content="{sskk:Image {StaticResource ImageGooglePlus}, 24, 24, NearestNeighbor}"> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="https://www.youtube.com/user/StrideCSG/" SnapsToDevicePixels="True" Margin="2" - Content="{sskk:Image {StaticResource ImageYoutube}, 24, 24, NearestNeighbor}"> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="https://www.twitch.tv/" SnapsToDevicePixels="True" Margin="2" - Content="{sskk:Image {StaticResource ImageTwitch}, 24, 24, NearestNeighbor}"> - </Button>--> - </StackPanel> - <UniformGrid Columns="2"> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Issues}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <Image Margin="5" Source="{StaticResource ImageIssues}" RenderOptions.BitmapScalingMode="HighQuality" Width="16" Height="16"/> - <TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Text="{x:Static r:Strings.ButtonIssues}" VerticalAlignment="Center"/> - </StackPanel> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Forums}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <Image Margin="5" Source="{StaticResource ImageForums}" RenderOptions.BitmapScalingMode="HighQuality" Width="16" Height="16"/> - <TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Text="{x:Static r:Strings.ButtonForums}" VerticalAlignment="Center"/> - </StackPanel> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Discord}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <Image Margin="5" Source="{StaticResource ImageChat}" RenderOptions.BitmapScalingMode="HighQuality" Width="16" Height="16"/> - <TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Text="{x:Static r:Strings.ButtonDiscord}" VerticalAlignment="Center"/> - </StackPanel> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Github}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <Image Margin="5" Source="{StaticResource ImageGithub}" RenderOptions.BitmapScalingMode="HighQuality" Width="16" Height="16"/> - <TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Text="{x:Static r:Strings.ButtonGithub}" VerticalAlignment="Center"/> - </StackPanel> - </Button> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.Roadmap}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <Image Margin="5" Source="{StaticResource ImageRoadmap}" RenderOptions.BitmapScalingMode="HighQuality" Width="16" Height="16"/> - <TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Text="{x:Static r:Strings.ButtonRoadmap}" VerticalAlignment="Center"/> - </StackPanel> - </Button> - </UniformGrid> - </DockPanel> - </Border> - <DockPanel> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" DockPanel.Dock="Top"> - <StackPanel> - <Grid Height="48"> - <!-- Launch game studio --> - <Button IsEnabled="{sskk:MultiBinding {Binding ActiveVersion.IsProcessing, Converter={sskk:InvertBool}}, - {Binding StartStudioCommand.IsEnabled}, Converter={sskk:AndMultiConverter}}" - Command="{Binding StartStudioCommand}" Content="{Binding ActiveVersion.FullName}" - HorizontalAlignment="Stretch" VerticalAlignment="Stretch" TextBlock.FontSize="24" - ContentStringFormat="{x:Static r:Strings.StartVersion}" Background="{StaticResource EmphasisButtonBackground}" ToolTipService.IsEnabled="False" - Visibility="{Binding ActiveVersion.CanStart, Converter={sskk:VisibleOrCollapsed}}" - ToolTip="{Binding ActiveVersion.FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipStartButton}}"> - <Button.Resources> - <!-- ReSharper disable once Xaml.RedundantResource - Overriding hovered color from theme--> - <SolidColorBrush x:Key="ButtonHoverBackgroundBrush" Color="{StaticResource EmphasisButtonHoverColor}"/> - </Button.Resources> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <!-- Install specific version --> - <Button IsEnabled="{sskk:MultiBinding {Binding ActiveVersion.IsProcessing, Converter={sskk:InvertBool}}, - {Binding ActiveVersion.DownloadCommand.IsEnabled}, Converter={sskk:AndMultiConverter}}" - Command="{Binding ActiveVersion.DownloadCommand}" Content="{Binding ActiveVersion.FullName}" - HorizontalAlignment="Stretch" VerticalAlignment="Stretch" TextBlock.FontSize="24" - ContentStringFormat="{x:Static r:Strings.InstallVersion}" Background="{StaticResource EmphasisButtonBackground}" ToolTipService.IsEnabled="False" - Visibility="{sskk:MultiBinding {Binding ActiveVersion.DownloadCommand.IsEnabled}, {Binding ActiveVersion.CanStart, Converter={sskk:InvertBool}}, Converter={sskk:MultiChained {sskk:AndMultiConverter}, {sskk:VisibleOrCollapsed}}}" - ToolTip="{Binding ActiveVersion.FullName, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipDownload}}"> - <Button.Resources> - <!-- ReSharper disable once Xaml.RedundantResource - Overriding hovered color from theme--> - <SolidColorBrush x:Key="ButtonHoverBackgroundBrush" Color="{StaticResource EmphasisButtonHoverColor}"/> - </Button.Resources> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - <!-- Install latest version (nothing installed) --> - <Button IsEnabled="{sskk:MultiBinding {Binding IsSynchronizing, Converter={sskk:InvertBool}}, - {Binding InstallLatestVersionCommand.IsEnabled}, Converter={sskk:AndMultiConverter}}" - Command="{Binding InstallLatestVersionCommand}" Content="{x:Static r:Strings.InstallLatestVersion}" - HorizontalAlignment="Stretch" VerticalAlignment="Stretch" TextBlock.FontSize="24" - Background="{StaticResource EmphasisButtonBackground}" ToolTipService.IsEnabled="False" - Visibility="{Binding ActiveVersion, Converter={sskk:Chained {sskk:ObjectToBool}, {sskk:InvertBool}, {sskk:VisibleOrCollapsed}}}" - ToolTip="{x:Static r:Strings.ToolTipInstallLatestVersion}"> - <Button.Resources> - <!-- ReSharper disable once Xaml.RedundantResource - Overriding hovered color from theme--> - <SolidColorBrush x:Key="ButtonHoverBackgroundBrush" Color="{StaticResource EmphasisButtonHoverColor}"/> - </Button.Resources> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - </Button> - </Grid> - <ComboBox Margin="0,4" Name="FrameworkSelector" IsEnabled="{Binding ActiveVersion.Frameworks.Count, Converter={sskk:Chained {sskk:IsGreater}, Parameter1={sskk:Double 1}}}" ItemsSource="{Binding ActiveVersion.Frameworks}" SelectedItem="{Binding ActiveVersion.SelectedFramework}" SelectionChanged="FrameworkChanged"> - <ComboBox.ItemTemplate> - <DataTemplate> - <TextBlock Text="{Binding Converter={vm:FrameworkConverter}}" /> - </DataTemplate> - </ComboBox.ItemTemplate> - </ComboBox> - <CheckBox Margin="0,2" DockPanel.Dock="Bottom" IsChecked="{Binding AutoCloseLauncher}" - Content="{x:Static r:Strings.AutoCloseLauncher}"/> - </StackPanel> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" DockPanel.Dock="Top"> - <DockPanel Height="32" Margin="5,0"> - <Image Source="{StaticResource ImageSwitchVersion}" Width="26" Height="26" RenderOptions.BitmapScalingMode="HighQuality" VerticalAlignment="Center"/> - <TextBlock Margin="10,0,0,0" Text="{x:Static r:Strings.SwitchOrUpdateVersion}" FontSize="24" TextAlignment="Left" VerticalAlignment="Center"/> - </DockPanel> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" DockPanel.Dock="Bottom"> - <StackPanel > - <ContentControl Grid.Column="0" Margin="2" Content="{Binding VsixPackage2019}"/> - <ContentControl Grid.Column="1" Margin="2" Content="{Binding VsixPackage2022}"/> - </StackPanel> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" DockPanel.Dock="Bottom"> - <DockPanel Height="20" Margin="5,0"> - <Button Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Left" - Command="{Binding OpenUrlCommand}" - CommandParameter="{x:Static r:Urls.VisualStudio}" - ToolTipService.IsEnabled="False" - ToolTip="{Binding CommandParameter, RelativeSource={RelativeSource Self}, Converter={sskk:FormatString}, ConverterParameter={x:Static r:Strings.ToolTipOpenLink}}"> - <i:Interaction.Behaviors> - <sskk:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" - DefaultValue="{x:Static r:Strings.ToolTipDefault}"/> - </i:Interaction.Behaviors> - <StackPanel Orientation="Horizontal" SnapsToDevicePixels="True"> - <TextBlock Margin="10,0,0,0" Text="{x:Static r:Strings.VisualStudioDownloadPage}" FontSize="12" TextAlignment="Left" VerticalAlignment="Center" Loaded="TextBlockVisualStudioDownloadPage_Loaded"/> - </StackPanel> - </Button> - </DockPanel> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="15,0,0,0" DockPanel.Dock="Bottom"> - <DockPanel Height="32" Margin="5,0"> - <Image Source="{StaticResource ImageVisualStudio}" Width="26" Height="26" RenderOptions.BitmapScalingMode="HighQuality" VerticalAlignment="Center"/> - <TextBlock Margin="10,0,0,0" Text="{x:Static r:Strings.VisualStudioExtension}" FontSize="24" TextAlignment="Left" VerticalAlignment="Center"/> - </DockPanel> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" - Background="Transparent"> - <ScrollViewer VerticalScrollBarVisibility="Auto" VerticalAlignment="Top"> - <StackPanel> - <ItemsControl ItemsSource="{Binding StrideVersions}" Margin="0,-2"/> - <ProgressBar Height="24" Margin="0,2" IsIndeterminate="True" Opacity="0.5" - Visibility="{Binding IsSynchronizing, Converter={sskk:VisibleOrCollapsed}}"/> - <ToggleButton Style="{StaticResource TransparentButtonStyle}" IsChecked="{Binding ShowBetaVersions}" Command="{Binding CheckDeprecatedSourcesCommand}" Margin="0,6" Content="{Binding}"> - <ToggleButton.ContentTemplate> - <DataTemplate> - <TextBlock x:Name="TextBlock" Text="{x:Static r:Strings.ToggleShowBetaVersions}"></TextBlock> - <DataTemplate.Triggers> - <DataTrigger Binding="{Binding ShowBetaVersions}" Value="True"> - <Setter Property="Text" TargetName="TextBlock" Value="{x:Static r:Strings.ToggleHideBetaVersions}"/> - </DataTrigger> - </DataTemplate.Triggers> - </DataTemplate> - </ToggleButton.ContentTemplate> - </ToggleButton> - </StackPanel> - </ScrollViewer> - </Border> - </DockPanel> - </DockPanel> - - <Grid Grid.Column="1" Grid.Row="0" Tag="{sskk:Double 0}" IsEnabled="{Binding ActiveReleaseNotes.IsActive, Converter={sskk:InvertBool}, FallbackValue={sskk:True}}"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*"/> - <ColumnDefinition Width="*"/> - </Grid.ColumnDefinitions> - <Grid.RenderTransform> - <TranslateTransform X="{sskk:MultiBinding {Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Grid}, - Converter={sskk:SumNum}, ConverterParameter={StaticResource ReleaseNotesMargin}}, - {Binding Tag, RelativeSource={RelativeSource AncestorType=Grid}}, Converter={sskk:MultiplyMultiConverter}}"/> - </Grid.RenderTransform> - <Grid.Style> - <Style TargetType="Grid"> - <Style.Triggers> - <Trigger Property="Grid.IsEnabled" Value="False"> - <Trigger.EnterActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="0" To="1" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.EnterActions> - <Trigger.ExitActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="1" To="0" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.ExitActions> - </Trigger> - </Style.Triggers> - </Style> - </Grid.Style> - - <!-- SECOND COLUMN --> - <Border Grid.Row="0" Grid.Column="0"> - <DockPanel> - <Border DockPanel.Dock="Top" BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}"> - <DockPanel Height="48" Margin="10,0"> - <Image Source="{StaticResource ImageProjects}" Width="26" Height="26" RenderOptions.BitmapScalingMode="HighQuality" VerticalAlignment="Center"/> - <TextBlock Margin="10,0,0,0" Text="{x:Static r:Strings.Projects}" FontSize="24" TextAlignment="Left" VerticalAlignment="Center"/> - </DockPanel> - </Border> - <Border DockPanel.Dock="Top" BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}" - Background="{StaticResource TileAlphaBackgroundBrush}" - Visibility="{Binding RecentProjects.Count, Converter={sskk:Chained {sskk:NumericToBool}, {sskk:InvertBool}, {sskk:VisibleOrCollapsed}}}"> - <TextBlock Text="{x:Static r:Strings.NoProjectCreated}" FontSize="16" Margin="20" TextAlignment="Center"/> - </Border> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}"> - <ScrollViewer VerticalScrollBarVisibility="Auto"> - <ScrollViewer.Style> - <Style BasedOn="{StaticResource {x:Type ScrollViewer}}" TargetType="ScrollViewer"> - <Style.Triggers> - <Trigger Property="ComputedVerticalScrollBarVisibility" Value="Visible"> - <Setter Property="Padding" Value="0,0,15,0"/> - </Trigger> - </Style.Triggers> - </Style> - </ScrollViewer.Style> - <ItemsControl ItemsSource="{Binding RecentProjects}"> - <ItemsControl.ItemTemplate> - <DataTemplate> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileListBorderThickness}"> - <Button Command="{Binding OpenCommand}" IsEnabled="{Binding Launcher.StartStudioCommand.IsEnabled}" - HorizontalContentAlignment="Stretch" Background="{StaticResource TileAlphaBackgroundBrush}"> - <DockPanel Margin="10,5"> - <DockPanel DockPanel.Dock="Bottom"> - <!-- Update dropdown --> - <ToggleButton x:Name="Toggle" Visibility="{Binding CompatibleVersions.Count, Converter={sskk:Chained {sskk:NumericToBool}, {sskk:VisibleOrCollapsed}}}" - DockPanel.Dock="Right" Content="{sskk:Image {StaticResource ImageUpgrade}, 16, 16}" Margin="4,0,0,0" Width="20" Height="20" - ToolTip="{x:Static r:Strings.ToolTipOpenAndUpgradeProject}" SnapsToDevicePixels="True"/> - <Popup IsOpen="{Binding ElementName=Toggle, Path=IsChecked}" StaysOpen="False" PlacementTarget="{Binding ElementName=Toggle}"> - <ScrollViewer Background="{StaticResource BackgroundBrush}" MinWidth="150" MaxHeight="400" VerticalScrollBarVisibility="Auto" BorderBrush="Black" BorderThickness="1"> - <ItemsControl ItemsSource="{Binding CompatibleVersions}" Margin="5"> - <ItemsControl.ItemTemplate> - <DataTemplate> - <Button Background="Transparent" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Click="OpenWithClicked" - Content="{Binding DisplayName}" ContentStringFormat="{x:Static r:Strings.OpenProjectWithVersion}" CommandParameter="{Binding}" - Command="{Binding DataContext.OpenWithCommand, RelativeSource={RelativeSource AncestorType=ScrollViewer}}" Padding="5,2"/> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </ScrollViewer> - </Popup> - <sskk:TextBox DockPanel.Dock="Bottom" IsReadOnly="True" Text="{Binding FullPath, Mode=OneWay}" Margin="0,10,0,0" - Foreground="{StaticResource TextBrush}" FontStyle="Italic" Background="Transparent" Focusable="False" - BorderThickness="0" sskk:Trimming.TextTrimming="WordEllipsis" sskk:Trimming.TrimmingSource="Middle" sskk:Trimming.WordSeparators="\/" x:Name="RecentTextBox"/> - </DockPanel> - <DockPanel> - <!-- Discovering --> - <TextBlock Text="{Binding StrideVersionName}" FontSize="14" - Foreground="{StaticResource TextBrush}" HorizontalAlignment="Right" DockPanel.Dock="Right" - Visibility="{Binding StrideVersionName, Converter={sskk:Chained {sskk:ObjectToBool}, {sskk:VisibleOrCollapsed}}}"/> - <!-- Unknown --> - <TextBlock Text="{x:Static r:Strings.UnknownVersion}" FontSize="14" - DockPanel.Dock="Right" Foreground="{StaticResource TextBrush}" HorizontalAlignment="Right" Margin="0,3" - Visibility="{Binding StrideVersionName, Converter={sskk:Chained {sskk:ObjectToBool}, {sskk:InvertBool}, {sskk:VisibleOrCollapsed}}}"/> - <TextBlock FontSize="16" FontWeight="Bold" Text="{Binding Name}" Foreground="{StaticResource TextBrush}"/> - </DockPanel> - </DockPanel> - </Button> - <Border.ContextMenu> - <ContextMenu> - <MenuItem Header="{x:Static r:Strings.ShowInExplorer}" Command="{Binding ExploreCommand}" /> - <MenuItem Header="{x:Static r:Strings.RemoveFromList}" Command="{Binding RemoveCommand}" /> - </ContextMenu> - </Border.ContextMenu> - </Border> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </ScrollViewer> - </Border> - </DockPanel> - </Border> - - <!-- THIRD COLUMN --> - <Border Grid.Row="0" Grid.Column="1" BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileBorderThickness}"> - <TabControl Background="{x:Null}" x:Name="TabControl" SelectionChanged="SelectedTabChanged"> - <TabItem Header="{x:Static r:Strings.TabGettingStarted}"> - <ScrollViewer VerticalScrollBarVisibility="Auto"> - <ScrollViewer.Style> - <Style BasedOn="{StaticResource {x:Type ScrollViewer}}" TargetType="ScrollViewer"> - <Style.Triggers> - <Trigger Property="ComputedVerticalScrollBarVisibility" Value="Visible"> - <Setter Property="Padding" Value="0,0,15,0"/> - </Trigger> - </Style.Triggers> - </Style> - </ScrollViewer.Style> - <StackPanel> - <Border Background="{StaticResource TileAlphaBackgroundBrush}" - Visibility="{Binding ActiveDocumentationPages.Count, Converter={sskk:Chained {sskk:NumericToBool}, {sskk:InvertBool}, {sskk:VisibleOrCollapsed}}, FallbackValue={sskk:Collapsed}}"> - <TextBlock Text="{x:Static r:Strings.NoDocumentation}" FontSize="16" Margin="20" TextAlignment="Center" VerticalAlignment="Center"/> - </Border> - <ItemsControl ItemsSource="{Binding ActiveDocumentationPages}"> - <ItemsControl.ItemTemplate> - <DataTemplate> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileListBorderThickness}"> - <Button Background="{StaticResource TileAlphaBackgroundBrush}" Command="{Binding OpenUrlCommand}" HorizontalContentAlignment="Stretch"> - <DockPanel Margin="5"> - <TextBlock Margin="0,3" FontSize="16" DockPanel.Dock="Top" Text="{Binding Title}" HorizontalAlignment="Left"/> - <TextBlock Margin="0,3" Text="{Binding Description}" Foreground="{StaticResource TextBrush}" TextWrapping="Wrap"/> - </DockPanel> - </Button> - </Border> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </StackPanel> - </ScrollViewer> - </TabItem> - <TabItem Header="{x:Static r:Strings.TabNews}"> - <ScrollViewer VerticalScrollBarVisibility="Auto"> - <ScrollViewer.Style> - <Style BasedOn="{StaticResource {x:Type ScrollViewer}}" TargetType="ScrollViewer"> - <Style.Triggers> - <Trigger Property="ComputedVerticalScrollBarVisibility" Value="Visible"> - <Setter Property="Padding" Value="0,0,15,0"/> - </Trigger> - </Style.Triggers> - </Style> - </ScrollViewer.Style> - <StackPanel> - <Border Background="{StaticResource TileAlphaBackgroundBrush}" - Visibility="{Binding NewsPages.Count, Converter={sskk:Chained {sskk:NumericToBool}, {sskk:InvertBool}, {sskk:VisibleOrCollapsed}}, FallbackValue={sskk:Collapsed}}"> - <TextBlock Text="{x:Static r:Strings.NoNews}" FontSize="16" Margin="20" TextAlignment="Center" VerticalAlignment="Center"/> - </Border> - <ItemsControl ItemsSource="{Binding NewsPages}"> - <ItemsControl.ItemTemplate> - <DataTemplate> - <Border BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource TileListBorderThickness}"> - <Button Command="{Binding OpenUrlCommand}" HorizontalContentAlignment="Stretch" Background="{StaticResource TileAlphaBackgroundBrush}"> - <DockPanel Margin="5"> - <TextBlock Margin="0,3" FontSize="16" DockPanel.Dock="Top" Text="{Binding Title}" HorizontalAlignment="Left" TextWrapping="Wrap"/> - <TextBlock Margin="0,8,0,3" Text="{Binding Date, StringFormat={x:Static r:Strings.NewsDate}}" Foreground="{StaticResource TextBrush}" HorizontalAlignment="Right"/> - </DockPanel> - </Button> - </Border> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </StackPanel> - </ScrollViewer> - </TabItem> - <TabControl.ItemsPanel> - <ItemsPanelTemplate> - <UniformGrid Rows="1" Height="32" Margin="{StaticResource TabHeaderMargin}"/> - </ItemsPanelTemplate> - </TabControl.ItemsPanel> - </TabControl> - </Border> - </Grid> - - <Grid Grid.Column="1" Grid.Row="0" Tag="{sskk:Double 1}" IsEnabled="{Binding ActiveReleaseNotes.IsActive, FallbackValue={sskk:False}}"> - <DockPanel Background="{StaticResource TileAlphaBackgroundBrush}" Margin="{StaticResource TileBorderThickness}"> - <DockPanel DockPanel.Dock="Top"> - <Button Content="{sskk:Image {StaticResource ImageCloseWindow}, 16, 16, NearestNeighbor}" DockPanel.Dock="Right" Margin="15,8" - Style="{StaticResource TransparentButtonStyle}" HorizontalAlignment="Right" Command="{Binding ActiveReleaseNotes.ToggleCommand}"/> - <TextBlock Margin="15,8" Text="{Binding ActiveReleaseNotes.Version, StringFormat=Release notes of version {0}}" FontSize="20" FontWeight="Bold"/> - </DockPanel> - <Grid> - <!-- BaseUrl must be set before Text so when ActiveReleaseNotes is updated, the binding of ImageBaseUrl is updated first, - then MarkdownContent is updated and the text converted, we will have a valid path for relative urls --> - <sskk:MarkdownTextBlock BaseUrl="{Binding ActiveReleaseNotes.BaseUrl, Mode=OneWay}" HyperlinkCommand="{x:Static l:Commands.OpenHyperlinkCommand}" - Text="{Binding ActiveReleaseNotes.MarkdownContent}" Background="Transparent" x:Name="ReleaseNotesPanel" - Margin="{StaticResource TileBorderThickness}" Visibility="{Binding ActiveReleaseNotes.IsLoaded, Converter={sskk:VisibleOrCollapsed}}"/> - <TextBlock Text="{x:Static r:Strings.NoReleaseNotes}" TextWrapping="Wrap" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center" - Visibility="{Binding ActiveReleaseNotes.IsUnavailable, Converter={sskk:VisibleOrCollapsed}}"/> - <TextBlock Text="{x:Static r:Strings.DownloadingReleaseNotes}" TextWrapping="Wrap" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center" - Visibility="{Binding ActiveReleaseNotes.IsLoading, Converter={sskk:VisibleOrCollapsed}}"/> - </Grid> - </DockPanel> - <Grid.RenderTransform> - <TranslateTransform X="{sskk:MultiBinding {Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Grid}, - Converter={sskk:SumNum}, ConverterParameter={StaticResource ReleaseNotesMargin}}, - {Binding Tag, RelativeSource={RelativeSource AncestorType=Grid}}, Converter={sskk:MultiplyMultiConverter}}"/> - </Grid.RenderTransform> - <Grid.Style> - <Style TargetType="Grid"> - <Style.Triggers> - <Trigger Property="Grid.IsEnabled" Value="True"> - <Trigger.EnterActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="1" To="0" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.EnterActions> - <Trigger.ExitActions> - <BeginStoryboard> - <Storyboard> - <DoubleAnimation From="0" To="1" Duration="0:0:0.5" AccelerationRatio="0.2" DecelerationRatio="0.1" Storyboard.TargetProperty="Tag"/> - </Storyboard> - </BeginStoryboard> - </Trigger.ExitActions> - </Trigger> - </Style.Triggers> - </Style> - </Grid.Style> - </Grid> - </Grid> - </Border> - </DockPanel> - <Border Visibility="{Binding Announcement, Converter={sskk:Chained {sskk:ObjectToBool}, {sskk:VisibleOrCollapsed}}}"> - <l:Announcement DataContext="{Binding Announcement}"/> - </Border> - </Grid> -</Window> diff --git a/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml.cs b/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml.cs deleted file mode 100644 index c7950d2378..0000000000 --- a/sources/launcher/Stride.Launcher/Views/LauncherWindow.xaml.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.ComponentModel; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Interop; -using System.Xml.Linq; -using Stride.Core.CodeEditorSupport.VisualStudio; -using Stride.Core.Packages; -using Stride.Core.Presentation.Dialogs; -using Stride.Core.Presentation.Extensions; -using Stride.Core.Presentation.View; -using Stride.Core.Presentation.ViewModels; -using Stride.LauncherApp.Services; -using Stride.LauncherApp.ViewModels; - -namespace Stride.LauncherApp.Views -{ - /// <summary> - /// Interaction logic for LauncherWindow.xaml - /// </summary> - public partial class LauncherWindow - { - - public LauncherWindow() - { - InitializeComponent(); - ExitOnUserClose = true; - Loaded += OnLoaded; - TabControl.SelectedIndex = LauncherSettings.CurrentTab >= 0 ? LauncherSettings.CurrentTab : 0; - } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - var handle = new WindowInteropHelper(this); - LauncherViewModel.WindowHandle = handle.Handle; - - InitializeWindowSize(); - } - - private void InitializeWindowSize() - { - var workArea = this.GetWorkArea(); - Width = Math.Min(Width, workArea.Width); - Height = Math.Min(Height, workArea.Height); - this.CenterToArea(workArea); - } - - public bool ExitOnUserClose { get; set; } - - private LauncherViewModel ViewModel => (LauncherViewModel)DataContext; - - internal void Initialize(NugetStore store, string defaultLogText = null) - { - var dispatcherService = new DispatcherService(Dispatcher); - var dialogService = new DialogService(dispatcherService, Launcher.ApplicationName); - var serviceProvider = new ViewModelServiceProvider(new object[] { dispatcherService, dialogService }); - DataContext = new LauncherViewModel(serviceProvider, store); - } - - protected override void OnClosing(CancelEventArgs e) - { - base.OnClosing(e); - - if (ViewModel.StrideVersions.Any(x => x.IsProcessing)) - { - var forceClose = Launcher.DisplayMessage("Some background operations are still in progress. Force close?"); - - if (!forceClose) - { - e.Cancel = true; - return; - } - } - - var viewModel = (LauncherViewModel)DataContext; - LauncherSettings.ActiveVersion = viewModel.ActiveVersion != null ? viewModel.ActiveVersion.Name : ""; - LauncherSettings.Save(); - if (ExitOnUserClose) - Environment.Exit(1); - } - - private void TextBlockVisualStudioDownloadPage_Loaded(object sender, RoutedEventArgs e) - { - bool hasCompatibleVersion = VisualStudioVersions.AvailableInstances - .Any(ide => ide.InstallationVersion.Major == 16 || ide.InstallationVersion.Major == 17); - - if (sender is TextBlock textBlockVisualStudioDownloadPage && hasCompatibleVersion) - { - textBlockVisualStudioDownloadPage.Visibility = Visibility.Collapsed; - } - } - - private void SelectedTabChanged(object sender, SelectionChangedEventArgs e) - { - LauncherSettings.CurrentTab = TabControl.SelectedIndex; - } - - private void FrameworkChanged(object sender, SelectionChangedEventArgs e) - { - var framework = (string)FrameworkSelector.SelectedItem; - if (framework != null && LauncherSettings.PreferredFramework != framework) - { - LauncherSettings.PreferredFramework = framework; - LauncherSettings.Save(); - } - } - - private void OpenWithClicked(object sender, RoutedEventArgs e) - { - var dependencyObject = sender as DependencyObject; - if (dependencyObject == null) - return; - - var scrollViewer = dependencyObject.FindVisualParentOfType<ScrollViewer>(); - scrollViewer?.FindLogicalParentOfType<Popup>()?.SetCurrentValue(Popup.IsOpenProperty, false); - } - } -} diff --git a/sources/launcher/Stride.Launcher/Views/MainView.axaml b/sources/launcher/Stride.Launcher/Views/MainView.axaml new file mode 100644 index 0000000000..0f46c19c42 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/MainView.axaml @@ -0,0 +1,749 @@ +<UserControl xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:sd="http://schemas.stride3d.net/xaml/presentation" + xmlns:mv="using:MarkView.Avalonia" + xmlns:p="using:Stride.Core.Presentation.Avalonia.Services" + xmlns:l="using:Stride.Launcher.Assets.Localization" + xmlns:srv="using:Stride.Launcher.Services" + xmlns:vm="using:Stride.Launcher.ViewModels" + xmlns:vw="using:Stride.Launcher.Views" + mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="768" + x:Class="Stride.Launcher.Views.MainView" + x:DataType="vm:MainViewModel"> + <UserControl.Resources> + <ResourceDictionary> + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <ImageBrush x:Key="ImageBackground"/> + </ResourceDictionary> + <ResourceDictionary x:Key="Dark"> + <ImageBrush x:Key="ImageBackground" + Source="/Assets/Images/robot.png" Stretch="UniformToFill"/> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <!-- FIXME xplat-editor find a background image working better with the light variant --> + <ImageBrush x:Key="ImageBackground" + Source="/Assets/Images/robot.png" Stretch="UniformToFill" Opacity="0.4"/> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + </ResourceDictionary> + </UserControl.Resources> + <UserControl.Background> + <DynamicResource ResourceKey="ImageBackground"/> + </UserControl.Background> + + <Grid ClipToBounds="True"> + <DockPanel> + <!-- Status --> + <Border DockPanel.Dock="Bottom" + Background="{DynamicResource BackgroundEmphasis}" + Padding="8,4"> + <Grid ColumnDefinitions="3*, *, Auto"> + <TextBlock Grid.Column="0" + HorizontalAlignment="Left" VerticalAlignment="Center" + Text="{Binding CurrentToolTip}" /> + <!-- Reconnect button --> + <Button Grid.Column="1" + Classes="TransparentButton" + Margin="8,4" Cursor="Hand" + HorizontalAlignment="Center" VerticalAlignment="Center" + ToolTip.Tip="Click to reconnect…" + IsVisible="{Binding IsOffline}" + Command="{Binding ReconnectCommand}"> + <TextBlock FontWeight="ExtraBold" Foreground="Gold" Text="Offline" /> + </Button> + <TextBlock Grid.Column="2" + Margin="8,4" HorizontalAlignment="Right" VerticalAlignment="Center" + Text="{Binding Source={x:Static srv:SelfUpdater.Version}, StringFormat=Launcher v{0}}"/> + </Grid> + </Border> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}"> + <Grid ColumnDefinitions="Auto, *"> + <!-- FIRST COLUMN --> + <DockPanel Grid.Column="0" Width="400"> + <Border DockPanel.Dock="Bottom" + BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}" + Background="{DynamicResource BackgroundTileAlpha}"> + <DockPanel Margin="10"> + <!-- Media buttons --> + <StackPanel DockPanel.Dock="Bottom" Margin="10" + Orientation="Horizontal" + HorizontalAlignment="Center"> + <Button Classes="TransparentButton MediaButton" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.XTwitter}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}" + Content="{StaticResource ImageTwitter}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + <Button Classes="TransparentButton MediaButton" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Facebook}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}" + Content="{StaticResource ImageFacebook}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + <Button Classes="TransparentButton MediaButton" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Reddit}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}" + Content="{StaticResource ImageReddit}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + <Button Classes="TransparentButton MediaButton" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.OpenCollective}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}" + Content="{StaticResource ImageOpenCollective}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + </StackPanel> + <!-- Community links --> + <UniformGrid Columns="2"> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Issues}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <StackPanel Orientation="Horizontal"> + <Image Margin="4" Width="16" Height="16" + Source="/Assets/Images/issues.png" /> + <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" + Text="{x:Static l:Strings.ButtonIssues}" /> + </StackPanel> + </Button> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Forums}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <StackPanel Orientation="Horizontal"> + <Image Margin="4" Width="16" Height="16" + Source="/Assets/Images/chat.png" /> + <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" + Text="{x:Static l:Strings.ButtonForums}" /> + </StackPanel> + </Button> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Discord}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <StackPanel Orientation="Horizontal"> + <Image Margin="4" Width="16" Height="16" + Source="/Assets/Images/discord.png" /> + <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" + Text="{x:Static l:Strings.ButtonDiscord}" /> + </StackPanel> + </Button> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.GitHub}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <StackPanel Orientation="Horizontal"> + <Image Margin="4" Width="16" Height="16" + Source="/Assets/Images/github.png" /> + <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" + Text="{x:Static l:Strings.ButtonGitHub}" /> + </StackPanel> + </Button> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.Roadmap}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <StackPanel Orientation="Horizontal"> + <Image Margin="4" Width="16" Height="16" + Source="/Assets/Images/roadmap.png" /> + <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" + Text="{x:Static l:Strings.ButtonRoadmap}" /> + </StackPanel> + </Button> + </UniformGrid> + </DockPanel> + </Border> + <DockPanel> + <Border DockPanel.Dock="Top" + BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}"> + <StackPanel> + <Grid Height="48"> + <!-- Launch game studio --> + <Button Command="{Binding StartStudioCommand}" + Content="{Binding ActiveVersion.FullName, StringFormat={x:Static l:Strings.StartVersion}}" FontSize="24" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + Background="{DynamicResource BackgroundEmphasis}" + IsEnabled="{Binding !ActiveVersion.IsProcessing}" + IsVisible="{Binding ActiveVersion.CanStart}" /> + <!-- Install specific version --> + <Button Command="{Binding ActiveVersion.DownloadCommand}" + Content="{Binding ActiveVersion.FullName, StringFormat={x:Static l:Strings.InstallVersion}}" FontSize="24" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + Background="{DynamicResource BackgroundEmphasis}" + IsEnabled="{Binding !ActiveVersion.IsProcessing}" + IsVisible="{sd:MultiBinding {Binding ActiveVersion.DownloadCommand.IsEnabled}, + {Binding !ActiveVersion.CanStart}, + Converter={sd:AndMulti}}" /> + <!-- Install latest version (nothing installed) --> + <Button Command="{Binding InstallLatestVersionCommand}" + Content="{x:Static l:Strings.InstallLatestVersion}" FontSize="24" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + Background="{DynamicResource BackgroundEmphasis}" + IsEnabled="{Binding !IsSynchronizing}" + IsVisible="{Binding ActiveVersion, Converter={sd:Chained {sd:ObjectToBool}, {sd:InvertBool}}}" /> + </Grid> + <ComboBox x:Name="FrameworkSelector" Margin="0,4" + HorizontalAlignment="Stretch" + ItemsSource="{Binding ActiveVersion.Frameworks}" + SelectedItem="{Binding ActiveVersion.SelectedFramework}" + SelectionChanged="FrameworkChanged" + IsEnabled="{Binding ActiveVersion.Frameworks.Count, Converter={sd:Chained {sd:IsGreater}, Parameter1={sd:Double 1}}}"> + <ComboBox.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding Converter={vm:FrameworkConverter}}" /> + </DataTemplate> + </ComboBox.ItemTemplate> + </ComboBox> + <CheckBox DockPanel.Dock="Bottom" Margin="0,2" + IsChecked="{Binding AutoCloseLauncher}" + Content="{x:Static l:Strings.AutoCloseLauncher}"/> + </StackPanel> + </Border> + <Border DockPanel.Dock="Top" + BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}"> + <DockPanel Height="32" Margin="4,0"> + <Image Source="/Assets/Images/switch-version.png" + Width="26" Height="26" + VerticalAlignment="Center" /> + <TextBlock Text="{x:Static l:Strings.SwitchOrUpdateVersion}" + Margin="10,0" + FontSize="24" TextAlignment="Left" VerticalAlignment="Center" /> + </DockPanel> + </Border> + <!-- Visual studio extensions --> + <Border DockPanel.Dock="Bottom" + IsVisible="{Binding Source={x:Static p:OperatingSystemHelper.IsWindows}, Mode=OneTime}" + BorderBrush="{StaticResource TileBorderBrush}" BorderThickness="{StaticResource BorderThicknessTile}"> + <StackPanel> + <StackPanel.DataTemplates> + <DataTemplate DataType="{x:Type vm:VsixVersionViewModel}"> + <Grid Height="32"> + <ProgressBar Minimum="0" Maximum="100" Value="{Binding CurrentProgress}" + IsIndeterminate="{Binding CurrentProgress, Converter={vw:ProgressToIndeterminatedConverter}}" + VerticalAlignment="Stretch" + IsVisible="{Binding IsProcessing}" /> + <TextBlock Text="{Binding CurrentProcessStatus}" + IsVisible="{Binding IsProcessing}" /> + <Button Command="{Binding ExecuteActionCommand}" + Content="{Binding Status}" + HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" + ToolTip.Tip="{Binding Status, StringFormat={x:Static l:Strings.ToolTipVisualStudioExtension}}" + IsVisible="{Binding !IsProcessing}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + </Grid> + </DataTemplate> + </StackPanel.DataTemplates> + <ContentControl Margin="2" Content="{Binding VsixPackage2019}"/> + <ContentControl Margin="2" Content="{Binding VsixPackage2022}"/> + </StackPanel> + </Border> + <Border DockPanel.Dock="Bottom" + IsVisible="{Binding Source={x:Static p:OperatingSystemHelper.IsWindows}, Mode=OneTime}" + BorderBrush="{StaticResource BorderBrushTile}"> + <DockPanel Margin="4,0"> + <Button Classes="TransparentButton" + HorizontalAlignment="Left" + Command="{Binding OpenUrlCommand}" + CommandParameter="{x:Static l:Urls.VisualStudio}" + ToolTip.Tip="{Binding CommandParameter, StringFormat={x:Static l:Strings.ToolTipOpenLink}, RelativeSource={RelativeSource Self}}" + Loaded="VisualStudioDownloadPage_Button_Loaded"> + <TextBlock Text="{x:Static l:Strings.VisualStudioDownloadPage}" + FontSize="12" Margin="10,0" + TextAlignment="Left" VerticalAlignment="Center" /> + </Button> + </DockPanel> + </Border> + <Border DockPanel.Dock="Bottom" + IsVisible="{Binding Source={x:Static p:OperatingSystemHelper.IsWindows}, Mode=OneTime}" + BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="12,0,0,0"> + <DockPanel Height="32" Margin="4,0"> + <Image Source="/Assets/Images/visual-studio.png" + Width="26" Height="26" + VerticalAlignment="Center" /> + <TextBlock Text="{x:Static l:Strings.VisualStudioExtension}" + Margin="10,0" + FontSize="24" TextAlignment="Left" VerticalAlignment="Center" /> + </DockPanel> + </Border> + <!-- Stride versions --> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}" + Background="Transparent"> + <ScrollViewer VerticalScrollBarVisibility="Auto" VerticalAlignment="Top" AllowAutoHide="False"> + <StackPanel Margin="0,0,16,0"> + <ItemsControl ItemsSource="{Binding StrideVersions}"> + <ItemsControl.DataTemplates> + <DataTemplate DataType="{x:Type vm:StrideDevVersionViewModel}"> + <DockPanel Height="32" Margin="0,2" + Background="{DynamicResource BackgroundTileAlpha}" + IsVisible="{Binding IsVisible}"> + <StackPanel DockPanel.Dock="Right" + MinWidth="32" Orientation="Horizontal" + IsEnabled="{Binding !IsProcessing}"> + <!-- Delete button --> + <Button Margin="4,0" Width="32" Height="32" + Command="{Binding DeleteCommand}" + IsVisible="{Binding CanDelete}" + ToolTip.Tip="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipUninstall}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <Image Source="/Assets/Images/delete.png" + Width="20" Height="20" /> + </Button> + </StackPanel> + <Grid> + <Button Command="{Binding SetAsActiveCommand}" + IsVisible="{Binding !IsProcessing}" + ToolTip.Tip="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipSetActiveVersion}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <TextBlock Text="{Binding DisplayName, StringFormat={x:Static l:Strings.VersionButton}}" /> + </Button> + </Grid> + </DockPanel> + </DataTemplate> + <DataTemplate DataType="{x:Type vm:StrideStoreVersionViewModel}"> + <DockPanel Height="32" Margin="0,2" + Background="{DynamicResource BackgroundTileAlpha}" + IsVisible="{Binding IsVisible}"> + <StackPanel DockPanel.Dock="Right" + MinWidth="32" Orientation="Horizontal" + IsEnabled="{Binding !IsProcessing}"> + <!-- Update button --> + <Button Margin="4,0,0,0" Width="32" Height="32" + Command="{Binding DownloadCommand}" + IsVisible="{sd:MultiBinding {Binding CanBeDownloaded}, {Binding CanDelete}, Converter={sd:AndMulti}}" + ToolTip.Tip="{Binding ServerVersionFullName, StringFormat={x:Static l:Strings.ToolTipUpdate}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <Image Source="/Assets/Images/update.png" + Width="20" Height="20" /> + </Button> + <!-- Download button --> + <Button Margin="4,0,0,0" Width="32" Height="32" + Command="{Binding DownloadCommand}" + IsVisible="{sd:MultiBinding {Binding CanBeDownloaded}, {Binding !CanDelete}, Converter={sd:AndMulti}}" + ToolTip.Tip="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipDownload}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <Image Source="/Assets/Images/download.png" + Width="20" Height="20" /> + </Button> + <!-- Delete button --> + <Button Margin="4,0,0,0" Width="32" Height="32" + Command="{Binding DeleteCommand}" + IsVisible="{Binding CanDelete}" + ToolTip.Tip="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipUninstall}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}" /> + </Interaction.Behaviors> + <Image Source="/Assets/Images/delete.png" + Width="20" Height="20" /> + </Button> + <!-- Alternate versions --> + <ToggleButton x:Name="Toggle" Margin="4,0,0,0" Width="32" Height="32" + ToolTip.Tip="Install another version"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <Image Source="/Assets/Images/list.png" + Width="20" Height="20" /> + </ToggleButton> + <Popup x:Name="Popup" + IsOpen="{Binding #Toggle.IsChecked, Mode=TwoWay}" IsLightDismissEnabled="True"> + <Border Margin="4" Background="{DynamicResource SystemChromeMediumLowColor}"> + <Border.Effect> + <DropShadowEffect BlurRadius="5" Opacity="0.4" /> + </Border.Effect> + <ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="256"> + <ItemsControl ItemsSource="{Binding AlternateVersions}"> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type vm:StrideStoreAlternateVersionViewModel}"> + <Button Content="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipSetActiveVersion}}" + Command="{Binding SetAsActiveCommand}" + HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"> + <Interaction.Behaviors> + <EventTriggerBehavior EventName="Click"> + <ChangePropertyAction TargetObject="{Binding #Popup}" PropertyName="IsOpen" Value="False" /> + </EventTriggerBehavior> + </Interaction.Behaviors> + </Button> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Border> + </Popup> + </StackPanel> + <!-- Release notes button --> + <Button DockPanel.Dock="Left" + Margin="0,0,4,0" Width="32" Height="32" + Command="{Binding ReleaseNotes.ToggleCommand}" + ToolTip.Tip="Display release notes for this version"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + <Image Source="/Assets/Images/note.png" + Width="20" Height="20" /> + </Button> + <Grid> + <ProgressBar Minimum="0" Maximum="100" Value="{Binding CurrentProgress}" + IsIndeterminate="{Binding CurrentProgress, Converter={vw:ProgressToIndeterminatedConverter}}" + VerticalAlignment="Stretch" + IsVisible="{Binding IsProcessing}" /> + <TextBlock Text="{Binding CurrentProcessStatus}" + HorizontalAlignment="Center" VerticalAlignment="Center" + IsVisible="{Binding IsProcessing}" /> + <Grid IsVisible="{Binding !IsProcessing}"> + <Button Command="{Binding SetAsActiveCommand}" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + IsVisible="{Binding CanDelete}" + ToolTip.Tip="{Binding FullName, StringFormat={x:Static l:Strings.ToolTipSetActiveVersion}}"> + <Interaction.Behaviors> + <sd:BindCurrentToolTipStringBehavior ToolTipTarget="{Binding Launcher.CurrentToolTip}" + DefaultValue="{x:Static l:Strings.ToolTipDefault}"/> + </Interaction.Behaviors> + </Button> + <TextBlock Margin="16,0" IsHitTestVisible="False" + HorizontalAlignment="Left" VerticalAlignment="Center"> + <Run Text="{Binding DisplayName, Mode=OneWay, StringFormat={x:Static l:Strings.VersionButton}}"/> + <!-- Careful here, there must not be any space or line-break between the next two Run tags! --> + <Run Text="{Binding !CanDelete, Mode=OneWay, Converter={sd:BoolToParam}, ConverterParameter={x:Static l:Strings.VersionButtonUninstalled} + }"/><Run Text="{sd:MultiBinding {Binding CanDelete}, {Binding CanBeDownloaded}, {Binding IsLatestPackageRemote}, Converter={sd:MultiChained + {sd:AndMulti}, {sd:BoolToParam}, Parameter1={x:Static l:Strings.VersionButtonUpdateAvailable}}}"/><Run Text="{sd:MultiBinding + {Binding CanDelete}, {Binding CanBeDownloaded}, {Binding IsLatestPackageLocal}, Converter={sd:MultiChained {sd:AndMulti}, {sd:BoolToParam}, + Parameter1={x:Static l:Strings.VersionButtonLocalUpdateAvailable}}}"/> + </TextBlock> + </Grid> + </Grid> + </DockPanel> + </DataTemplate> + </ItemsControl.DataTemplates> + </ItemsControl> + <ProgressBar IsIndeterminate="True" + Height="24" Margin="0,2" + Opacity="0.5" + IsVisible="{Binding IsSynchronizing}" /> + <ToggleButton Classes="TransparentButton" + Margin="0,4" HorizontalAlignment="Center" + IsChecked="{Binding ShowBetaVersions}" + Content="{Binding}" + Command="{Binding CheckDeprecatedSourcesCommand}"> + <ToggleButton.ContentTemplate> + <DataTemplate DataType="vm:MainViewModel"> + <TextBlock Text="{x:Static l:Strings.ToggleShowBetaVersions}"> + <Interaction.Behaviors> + <DataTriggerBehavior Binding="{Binding ShowBetaVersions}" Value="True"> + <ChangePropertyAction PropertyName="Text" Value="{x:Static l:Strings.ToggleHideBetaVersions}" /> + </DataTriggerBehavior> + <DataTriggerBehavior Binding="{Binding ShowBetaVersions}" Value="False"> + <ChangePropertyAction PropertyName="Text" Value="{x:Static l:Strings.ToggleShowBetaVersions}" /> + </DataTriggerBehavior> + </Interaction.Behaviors> + </TextBlock> + </DataTemplate> + </ToggleButton.ContentTemplate> + </ToggleButton> + </StackPanel> + </ScrollViewer> + </Border> + </DockPanel> + </DockPanel> + + <Grid Grid.Column="1" + ColumnDefinitions="*, *" + IsEnabled="{Binding !ActiveReleaseNotes.IsActive, FallbackValue={x:True}}"> + <!-- SECOND COLUMN --> + <Border Grid.Column="0"> + <!-- Recent projects --> + <DockPanel> + <Border DockPanel.Dock="Top"> + <DockPanel Height="48" Margin="10,0"> + <Image Source="/Assets/Images/recent-projects.png" + Width="26" Height="26" + VerticalAlignment="Center" /> + <TextBlock Text="{x:Static l:Strings.Projects}" + Margin="10,0" + FontSize="24" TextAlignment="Left" VerticalAlignment="Center" /> + </DockPanel> + </Border> + <Grid> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}" + Background="{DynamicResource BackgroundTileAlpha}" + IsVisible="{Binding !RecentProjects.Count, FallbackValue={x:False}}" + VerticalAlignment="Top"> + <TextBlock Text="{x:Static l:Strings.NoProjectCreated}" + FontSize="16" Margin="20" + TextAlignment="Center" VerticalAlignment="Center" /> + </Border> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTile}"> + <ScrollViewer VerticalScrollBarVisibility="Auto"> + <ItemsControl ItemsSource="{Binding RecentProjects}"> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type vm:RecentProjectViewModel}"> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTileList}"> + <Button Command="{Binding OpenCommand}" + IsEnabled="{Binding Launcher.StartStudioCommand.IsEnabled}" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + HorizontalContentAlignment="Stretch"> + <Button.ContextMenu> + <ContextMenu> + <MenuItem Header="{x:Static l:Strings.ShowInExplorer}" Command="{Binding ExploreCommand}" /> + <MenuItem Header="{x:Static l:Strings.RemoveFromList}" Command="{Binding RemoveCommand}" /> + </ContextMenu> + </Button.ContextMenu> + <DockPanel Margin="8,4"> + <DockPanel DockPanel.Dock="Bottom"> + <!-- Update dropdown --> + <ToggleButton DockPanel.Dock="Right" x:Name="Toggle" + IsVisible="{Binding CompatibleVersions.Count}"/> + <Popup IsOpen="{Binding ElementName=Toggle, Path=IsChecked}" + PlacementTarget="{Binding ElementName=Toggle}"> + <ScrollViewer VerticalScrollBarVisibility="Auto"> + <ItemsControl ItemsSource="{Binding CompatibleVersions}"> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type vm:StrideVersionViewModel}"> + <Button Content="{Binding DisplayName, StringFormat={x:Static l:Strings.OpenProjectWithVersion}}" + Command="{Binding $parent[ScrollViewer].((vm:RecentProjectViewModel)DataContext).OpenWithCommand}" + HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Popup> + <TextBlock DockPanel.Dock="Bottom" + Text="{Binding FullPath}" + FontStyle="Italic" /> + </DockPanel> + <DockPanel> + <!-- Discovering --> + <TextBlock Text="{Binding StrideVersionName}" + FontSize="14" + IsVisible="{Binding StrideVersionName, Converter={sd:ObjectToBool}}" /> + <!-- Unknown --> + <TextBlock Text="{x:Static l:Strings.UnknownVersion}" + FontSize="14" + IsVisible="{Binding StrideVersionName, Converter={sd:Chained {sd:ObjectToBool}, {sd:InvertBool}}}" /> + <TextBlock Text="{Binding Name}" + FontSize="16" FontWeight="Bold" /> + </DockPanel> + </DockPanel> + </Button> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Border> + </Grid> + </DockPanel> + </Border> + + <!-- THIRD COLUMN --> + <Border Grid.Column="1"> + <TabControl SelectedIndex="{Binding CurrentTab, Mode=TwoWay}"> + <TabControl.ItemsPanel> + <ItemsPanelTemplate> + <UniformGrid Rows="1" Height="32" Margin="0,12,0,32"/> + </ItemsPanelTemplate> + </TabControl.ItemsPanel> + <TabControl.Styles> + <Style Selector="TabItem"> + <Setter Property="FontSize" Value="18" /> + </Style> + </TabControl.Styles> + <!-- Getting started --> + <TabItem Header="{x:Static l:Strings.TabGettingStarted}"> + <ScrollViewer> + <StackPanel> + <Border IsVisible="{Binding !ActiveDocumentationPages.Count, FallbackValue={x:False}}"> + <TextBlock Text="{x:Static l:Strings.NoDocumentation}" + FontSize="16" Margin="20" + TextAlignment="Center" VerticalAlignment="Center" /> + </Border> + <ItemsControl ItemsSource="{Binding ActiveDocumentationPages}"> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type vm:DocumentationPageViewModel}"> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTileList}"> + <Button Command="{Binding OpenUrlCommand}" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Stretch"> + <DockPanel> + <TextBlock DockPanel.Dock="Top" + HorizontalAlignment="Left" + Text="{Binding Title}" /> + <TextBlock Text="{Binding Description}" TextWrapping="Wrap" /> + </DockPanel> + </Button> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + </ScrollViewer> + </TabItem> + <!-- News --> + <TabItem Header="{x:Static l:Strings.TabNews}"> + <ScrollViewer> + <StackPanel> + <Border Background="{DynamicResource BackgroundTileAlpha}" + IsVisible="{Binding !NewsPages.Count, FallbackValue={x:False}}"> + <TextBlock Text="{x:Static l:Strings.NoNews}" + FontSize="16" Margin="20" + TextAlignment="Center" VerticalAlignment="Center" /> + </Border> + <ItemsControl ItemsSource="{Binding NewsPages}"> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type vm:NewsPageViewModel}"> + <Border BorderBrush="{StaticResource BorderBrushTile}" BorderThickness="{StaticResource BorderThicknessTileList}"> + <Button Command="{Binding OpenUrlCommand}" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Stretch"> + <DockPanel Margin="4"> + <TextBlock DockPanel.Dock="Top" + Margin="0,4" HorizontalAlignment="Left" + FontSize="16" + Text="{Binding Title}" TextWrapping="Wrap" /> + <TextBlock Margin="0,8,0,4" HorizontalAlignment="Right" + Text="{Binding Date, StringFormat={x:Static l:Strings.NewsDate}}" /> + </DockPanel> + </Button> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + </ScrollViewer> + </TabItem> + </TabControl> + </Border> + </Grid> + + <!-- Release notes --> + <Grid Grid.Column="1" + Classes="rn-slide" + Classes.visible="{Binding ActiveReleaseNotes.IsActive, FallbackValue={x:False}}" + IsHitTestVisible="{Binding ActiveReleaseNotes.IsActive, FallbackValue={x:False}}" + IsEnabled="{Binding ActiveReleaseNotes.IsActive, FallbackValue={x:False}}"> + <Grid.Styles> + <Style Selector="Grid.rn-slide.visible"> + <Setter Property="RenderTransform" Value="translateX(0)"/> + </Style> + <Style Selector="Grid.rn-slide:not(.visible)"> + <Setter Property="RenderTransform" Value="translateX(2000px)"/> + </Style> + </Grid.Styles> + <Grid.Transitions> + <Transitions> + <TransformOperationsTransition Property="RenderTransform" Duration="0:0:1.0" Easing="CubicEaseOut"/> + </Transitions> + </Grid.Transitions> + <DockPanel Background="{DynamicResource BackgroundTileAlpha}"> + <DockPanel DockPanel.Dock="Top"> + <Button DockPanel.Dock="Right" + Classes="TransparentButton" + Margin="8" + Command="{Binding ActiveReleaseNotes.ToggleCommand}" + Content="×" FontSize="20" /> + <TextBlock Margin="15,8" + Text="{Binding ActiveReleaseNotes.Version, StringFormat='Release notes of version {0}'}" + FontSize="20" FontWeight="Bold" VerticalAlignment="Center" /> + </DockPanel> + <Grid Margin="{StaticResource BorderThicknessTile}"> + <mv:MarkdownViewer Markdown="{Binding ActiveReleaseNotes.MarkdownContent}" + IsVisible="{Binding ActiveReleaseNotes.IsLoaded}" /> + <TextBlock Text="{x:Static l:Strings.NoReleaseNotes}" + TextWrapping="Wrap" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center" + IsVisible="{Binding ActiveReleaseNotes.IsUnavailable}" /> + <TextBlock Text="{x:Static l:Strings.DownloadingReleaseNotes}" + TextWrapping="Wrap" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center" + IsVisible="{Binding ActiveReleaseNotes.IsLoading}" /> + </Grid> + </DockPanel> + </Grid> + </Grid> + </Border> + </DockPanel> + <!-- Announcements --> + <Border Classes="ann-slide" + Classes.visible="{Binding Announcement, Converter={sd:ObjectToBool}}" + IsHitTestVisible="{Binding Announcement, Converter={sd:ObjectToBool}, FallbackValue={x:False}}"> + <Border.Styles> + <Style Selector="Border.ann-slide.visible"> + <Setter Property="RenderTransform" Value="translateX(0)"/> + </Style> + <Style Selector="Border.ann-slide:not(.visible)"> + <Setter Property="RenderTransform" Value="translateX(2000px)"/> + </Style> + </Border.Styles> + <Border.Transitions> + <Transitions> + <TransformOperationsTransition Property="RenderTransform" Duration="0:0:1.0" Easing="CubicEaseOut"/> + </Transitions> + </Border.Transitions> + <vw:Announcement DataContext="{Binding Announcement}" /> + </Border> + </Grid> +</UserControl> diff --git a/sources/launcher/Stride.Launcher/Views/MainView.axaml.cs b/sources/launcher/Stride.Launcher/Views/MainView.axaml.cs new file mode 100644 index 0000000000..d760b8726a --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/MainView.axaml.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Controls; +using Avalonia.Interactivity; +using Stride.Core.CodeEditorSupport.VisualStudio; +using Stride.Launcher.ViewModels; + +namespace Stride.Launcher.Views; + +public partial class MainView : UserControl +{ + public MainView() + { + InitializeComponent(); + } + + private void FrameworkChanged(object? sender, SelectionChangedEventArgs e) + { + if (DataContext is MainViewModel vm + && FrameworkSelector.SelectedItem is string framework + && vm.PreferredFramework != framework) + { + vm.PreferredFramework = framework; + } + } + + private void VisualStudioDownloadPage_Button_Loaded(object? sender, RoutedEventArgs e) + { + if (sender is Button button && VisualStudioVersions.AvailableInstances + .Any(ide => ide.InstallationVersion.Major == 16 || ide.InstallationVersion.Major == 17)) + { + button.IsVisible = false; + } + } +} diff --git a/sources/launcher/Stride.Launcher/Views/MainWindow.axaml b/sources/launcher/Stride.Launcher/Views/MainWindow.axaml new file mode 100644 index 0000000000..7c59fae544 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/MainWindow.axaml @@ -0,0 +1,13 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:l="using:Stride.Launcher.Assets.Localization" + xmlns:vw="using:Stride.Launcher.Views" + mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="768" + Width="1024" Height="768" WindowStartupLocation="CenterScreen" + x:Class="Stride.Launcher.Views.MainWindow" + Icon="/Assets/Launcher.ico" + Title="{x:Static l:Strings.LauncherTitle}"> + <vw:MainView /> +</Window> diff --git a/sources/launcher/Stride.Launcher/Views/MainWindow.axaml.cs b/sources/launcher/Stride.Launcher/Views/MainWindow.axaml.cs new file mode 100644 index 0000000000..28cac49aa3 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/MainWindow.axaml.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Controls; +using Stride.Launcher.ViewModels; + +namespace Stride.Launcher.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + // When the cross-platform Game Studio port (xplat-editor) lands, the Win32 + // HWND hand-off below needs to be replaced with a cross-platform IPC token + // (e.g. a named-pipe path) passed via a generalised CLI argument. See + // docs/launcher/port-status.md Phase 1 for the rationale. + if (OperatingSystem.IsWindows()) + { + var platformHandle = TryGetPlatformHandle(); + if (platformHandle is not null) + { + MainViewModel.WindowHandle = platformHandle.Handle; + } + } + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (DataContext is not MainViewModel vm) + { + return; + } + + // Avalonia's OnClosing is synchronous. Cancel the close unconditionally, + // run the async confirmation, then exit explicitly if the user confirms. + e.Cancel = true; + _ = OnClosingAsync(vm); + } + + private static async Task OnClosingAsync(MainViewModel vm) + { + if (await vm.TryCloseAsync()) + { + // Matches master's exit code. ShutdownMode is OnExplicitShutdown so we + // can't rely on the main window close to terminate the process. + Environment.Exit(1); + } + } +} diff --git a/sources/launcher/Stride.Launcher/Views/ProgressToIndeterminatedConverter.cs b/sources/launcher/Stride.Launcher/Views/ProgressToIndeterminatedConverter.cs index 09b802df65..674a3b561e 100644 --- a/sources/launcher/Stride.Launcher/Views/ProgressToIndeterminatedConverter.cs +++ b/sources/launcher/Stride.Launcher/Views/ProgressToIndeterminatedConverter.cs @@ -1,18 +1,15 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; + using System.Globalization; +using Stride.Core.Presentation.Avalonia.Converters; -using Stride.Core.Presentation.ValueConverters; +namespace Stride.Launcher.Views; -namespace Stride.LauncherApp.Views +public class ProgressToIndeterminatedConverter : OneWayValueConverter<ProgressToIndeterminatedConverter> { - public class ProgressToIndeterminatedConverter : OneWayValueConverter<ProgressToIndeterminatedConverter> + public override object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - var progress = (int)System.Convert.ChangeType(value, typeof(int)); - return progress <= 0; - } + return (int?)System.Convert.ChangeType(value, typeof(int)) <= 0; } } diff --git a/sources/launcher/Stride.Launcher/Views/ProgressToRectConverter.cs b/sources/launcher/Stride.Launcher/Views/ProgressToRectConverter.cs deleted file mode 100644 index 3212b64250..0000000000 --- a/sources/launcher/Stride.Launcher/Views/ProgressToRectConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Globalization; -using System.Linq; -using System.Windows; - -using Stride.Core.Presentation.ValueConverters; - -namespace Stride.LauncherApp.Views -{ - public class ProgressToRectConverter : OneWayMultiValueConverter<ProgressToRectConverter> - { - public override object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Any(x => x == DependencyProperty.UnsetValue)) - return new Rect(0, 0, 1, 1); - - var width = (double)values[0]; - var height = (double)values[1]; - var progress = (double)System.Convert.ChangeType(values[2], typeof(double)); - return new Rect(0, 0, width * (progress > 0 ? progress * 0.01 : 1.0), height) ; - } - } -} diff --git a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml new file mode 100644 index 0000000000..9c7409dc05 --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml @@ -0,0 +1,24 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:l="using:Stride.Launcher.Assets.Localization" + mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="320" + Width="640" Height="320" WindowStartupLocation="CenterScreen" + x:Class="Stride.Launcher.SelfUpdateWindow" + Title="{x:Static l:Strings.SelfUpdateTitle}"> + <DockPanel> + <TextBlock DockPanel.Dock="Top" + Text="{x:Static l:Strings.SelfUpdateMessage}" + Margin="40" + HorizontalAlignment="Center" VerticalAlignment="Center" /> + <Button DockPanel.Dock="Bottom" x:Name="ExitButton" + Width="100" Margin="40" + HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" + Content="{x:Static l:Strings.SelfUpdateExit}" + Click="ExitButtonClicked" /> + <ProgressBar Height="32" Margin="40,0" + VerticalAlignment="Center" + IsIndeterminate="True" /> + </DockPanel> +</Window> diff --git a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml.cs b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml.cs new file mode 100644 index 0000000000..bdf898b1cb --- /dev/null +++ b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.axaml.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace Stride.Launcher; + +public partial class SelfUpdateWindow : Window +{ + public SelfUpdateWindow() + { + InitializeComponent(); + + if (Screens.ScreenFromWindow(this)?.WorkingArea is PixelRect area) + { + Width = Math.Min(Width, area.Width); + Height = Math.Min(Height, area.Height); + } + + // Allow closing only when Exit button is enabled. + Closing += (sender, e) => e.Cancel = !ExitButton.IsEnabled; + } + + /// <summary> + /// Forcibly close the update window. + /// </summary> + public void ForceClose() + { + ExitButton.IsEnabled = true; + Close(); + } + + /// <summary> + /// Prevents window from being closed during a critical section of the update process. + /// </summary> + public void LockWindow() + { + ExitButton.IsEnabled = false; + } + + private void ExitButtonClicked(object? sender, RoutedEventArgs e) + { + if (Application.Current is App app) + { + app.cts.Cancel(); + } + else + { + Environment.Exit(0); + } + } +} diff --git a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml deleted file mode 100644 index be0e2f281d..0000000000 --- a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml +++ /dev/null @@ -1,14 +0,0 @@ -<Window x:Class="Stride.LauncherApp.Views.SelfUpdateWindow" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:r="clr-namespace:Stride.LauncherApp.Resources" - Style="{DynamicResource WindowChromeStyle}" Icon="{DynamicResource LauncherIcon}" ResizeMode="NoResize" - Title="{x:Static r:Strings.SelfUpdateTitle}" Height="300" Width="640" WindowStartupLocation="CenterOwner"> - <DockPanel> - <TextBlock DockPanel.Dock="Top" Text="{x:Static r:Strings.SelfUpdateMessage}" - HorizontalAlignment="Center" VerticalAlignment="Center" Margin="40"/> - <Button x:Name="ExitButton" DockPanel.Dock="Bottom" Width="100" Margin="40" Content="{x:Static r:Strings.SelfUpdateExit}" Padding="4" - Click="ExitButtonClicked"/> - <ProgressBar Height="32" Margin="40,0" VerticalAlignment="Center" IsIndeterminate="True"/> - </DockPanel> -</Window> diff --git a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml.cs b/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml.cs deleted file mode 100644 index 3a67a88220..0000000000 --- a/sources/launcher/Stride.Launcher/Views/SelfUpdateWindow.xaml.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Windows; - -namespace Stride.LauncherApp.Views -{ - /// <summary> - /// Interaction logic for SelfUpdateWindow.xaml - /// </summary> - public partial class SelfUpdateWindow - { - /// <summary> - /// Initialize new instance of a <see cref="SelfUpdateWindow"/>. - /// </summary> - public SelfUpdateWindow() - { - InitializeComponent(); - Width = Math.Min(Width, SystemParameters.WorkArea.Width); - Height = Math.Min(Height, SystemParameters.WorkArea.Height); - // Allow closing only when Exit button is enabled. - Closing += (sender, e) => e.Cancel = !ExitButton.IsEnabled; - } - - /// <summary> - /// Prevents window from being closed during a critical section of the update process. - /// </summary> - public void LockWindow() - { - ExitButton.IsEnabled = false; - } - - /// <summary> - /// Forcibly close the update window. - /// </summary> - public void ForceClose() - { - ExitButton.IsEnabled = true; - Close(); - } - - private void ExitButtonClicked(object sender, RoutedEventArgs e) - { - Environment.Exit(0); - } - } -} diff --git a/sources/launcher/Stride.Launcher/Views/StaysOpenContextMenu.cs b/sources/launcher/Stride.Launcher/Views/StaysOpenContextMenu.cs deleted file mode 100644 index b9019a693f..0000000000 --- a/sources/launcher/Stride.Launcher/Views/StaysOpenContextMenu.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace Stride.LauncherApp.Views -{ - public class StaysOpenContextMenu : ContextMenu - { - private bool mustStayOpen; - - static StaysOpenContextMenu() - { - IsOpenProperty.OverrideMetadata( - typeof(StaysOpenContextMenu), - new FrameworkPropertyMetadata(false, null, CoerceIsOpen)); - StaysOpenProperty.OverrideMetadata( - typeof(StaysOpenContextMenu), - new FrameworkPropertyMetadata(false, PropertyChanged, CoerceStaysOpen)); - } - - private static void PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - StaysOpenContextMenu menu = (StaysOpenContextMenu)d; - menu.mustStayOpen = (bool)e.NewValue; - } - - private static object CoerceStaysOpen(DependencyObject d, object basevalue) - { - d.CoerceValue(IsOpenProperty); - return basevalue; - } - - private static object CoerceIsOpen(DependencyObject d, object basevalue) - { - StaysOpenContextMenu menu = (StaysOpenContextMenu)d; - if (menu.StaysOpen && menu.mustStayOpen) - { - return true; - } - - return basevalue; - } - - public void CloseContextMenu() - { - this.mustStayOpen = false; - this.IsOpen = false; - } - } -} diff --git a/sources/launcher/Stride.Launcher/app.manifest b/sources/launcher/Stride.Launcher/app.manifest index 9b62162050..54fec7ea7c 100644 --- a/sources/launcher/Stride.Launcher/app.manifest +++ b/sources/launcher/Stride.Launcher/app.manifest @@ -1,6 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> - <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/> + <!-- This manifest is used on Windows only. + Don't remove it as it might cause problems with window transparency and embeded controls. + For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests --> + <assemblyIdentity version="1.0.0.0" name="AvaloniaTest.Desktop"/> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <security> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> @@ -26,48 +29,10 @@ <!-- A list of the Windows versions that this application has been tested on and is designed to work with. Uncomment the appropriate elements and Windows will automatically select the most compatible environment. --> - <!-- Windows Vista --> - <!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />--> - <!-- Windows 7 --> - <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" /> - <!-- Windows 8 --> - <!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />--> <!-- Windows 8.1 --> <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /> - <!-- Windows 10 --> + <!-- Windows 10 and later --> <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> </application> </compatibility> - - <!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher - DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need - to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should - also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. --> - - <application xmlns="urn:schemas-microsoft-com:asm.v3"> - <windowsSettings> - <!-- The combination of below two tags have the following effect : - 1) Per-Monitor for >= RS1 (Windows 10 Anniversary Update) - 2) System < RS1 - --> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitor</dpiAwareness> - <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> - </windowsSettings> - </application> - - - <!-- Enable themes for Windows common controls and dialogs (Windows XP and later) --> - <dependency> - <dependentAssembly> - <assemblyIdentity - type="win32" - name="Microsoft.Windows.Common-Controls" - version="6.0.0.0" - processorArchitecture="*" - publicKeyToken="6595b64144ccf1df" - language="*" - /> - </dependentAssembly> - </dependency> - </assembly> diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Behaviors/BindCurrentToolTipStringBehavior.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Behaviors/BindCurrentToolTipStringBehavior.cs new file mode 100644 index 0000000000..afdd1008aa --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Behaviors/BindCurrentToolTipStringBehavior.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Xaml.Interactivity; + +namespace Stride.Core.Presentation.Avalonia.Behaviors; + +/// <summary> +/// Allows the bind the <see cref="ToolTip.TipProperty"/> of a control to a particular target property when the attached control is hovered by the mouse. +/// This behavior can be used to display the same message that the tool-tip in a status bar, for example. +/// </summary> +/// <remarks>This behavior can be used to display the tool tip of some controls in another place, such as a status bar.</remarks> +public class BindCurrentToolTipStringBehavior : Behavior<Control> +{ + /// <summary> + /// Identifies the <see cref="ToolTipTarget"/> dependency property. + /// </summary> + public static readonly StyledProperty<string?> ToolTipTargetProperty = + AvaloniaProperty.Register<BindCurrentToolTipStringBehavior, string?>(nameof(ToolTipTarget), defaultBindingMode: BindingMode.TwoWay); + + /// <summary> + /// Identifies the <see cref="DefaultValue"/> dependency property. + /// </summary> + public static readonly StyledProperty<string?> DefaultValueProperty = + AvaloniaProperty.Register<BindCurrentToolTipStringBehavior, string?>(nameof(DefaultValue), defaultBindingMode: BindingMode.TwoWay); + + /// <summary> + /// Gets or sets the tool tip text of the control when the mouse is over the control, or <see cref="DefaultValue"/> otherwise. This property should usually be bound. + /// </summary> + public string? ToolTipTarget + { + get => GetValue(ToolTipTargetProperty); + set => SetValue(ToolTipTargetProperty, value); + } + + /// <summary> + /// Gets or sets the default value to set when the mouse is not over the control. + /// </summary> + public string? DefaultValue + { + get => GetValue(DefaultValueProperty); + set => SetValue(DefaultValueProperty, value); + } + + /// <inheritdoc/> + protected override void OnAttached() + { + base.OnAttached(); + if (AssociatedObject is not null) + { + AssociatedObject.PointerEntered += MouseEnter; + AssociatedObject.PointerExited += MouseLeave; + } + } + + /// <inheritdoc/> + protected override void OnDetaching() + { + if (AssociatedObject is not null) + { + AssociatedObject.PointerEntered -= MouseEnter; + AssociatedObject.PointerExited -= MouseLeave; + } + base.OnDetaching(); + } + + private void MouseEnter(object? sender, PointerEventArgs e) + { + if (AssociatedObject is not null) + { + SetCurrentValue(ToolTipTargetProperty, ToolTip.GetTip(AssociatedObject)); + } + } + + private void MouseLeave(object? sender, PointerEventArgs e) + { + SetCurrentValue(ToolTipTargetProperty, DefaultValue); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/AndMulti.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/AndMulti.cs new file mode 100644 index 0000000000..cc0888c426 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/AndMulti.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia; +using Stride.Core.Presentation.Avalonia.Internal; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <seealso cref="OrMulti"/> +/// <seealso cref="XOrMulti"/> +public sealed class AndMulti : MultiValueConverterBase<AndMulti> +{ + public override object Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2) return AvaloniaProperty.UnsetValue; + + return values.All(x => x is true).Box(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToParam.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToParam.cs new file mode 100644 index 0000000000..94772e28a6 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToParam.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia; +using Stride.Core.Presentation.Avalonia.Internal; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// This converter will convert a boolean to the object given in parameter if its true, +/// and to <see cref="AvaloniaProperty.UnsetValue"/> if it's false. +/// <see cref="ConvertBack"/> is supported and will return whether the given object is different from +/// <see cref="AvaloniaProperty.UnsetValue"/>. +/// </summary> +public sealed class BoolToParam : ValueConverterBase<BoolToParam> +{ + /// <inheritdoc/> + public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var result = ConverterHelper.ConvertToBoolean(value, culture); + return result ? parameter : AvaloniaProperty.UnsetValue; + } + + /// <inheritdoc/> + public override object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var result = value != AvaloniaProperty.UnsetValue; + return result.Box(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/Chained.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/Chained.cs new file mode 100644 index 0000000000..fb341d2bd9 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/Chained.cs @@ -0,0 +1,295 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// This converter can chain up to <see cref="MaxConverterCount"/> <see cref="IValueConverter"/> to convert a value. The first converter takes +/// the value parameter of the Chained value converter itself, and then each converter takes the previous converter output as input value. +/// The parameter and target type of each converter can also be specified. <see cref="IValueConverter.ConvertBack"/> is supported and converters are invoked backward. +/// </summary> +/// <remarks>This converter is also a <see cref="MarkupExtension"/>, which makes it convenient to use in XAML.</remarks> +public sealed class Chained : MarkupExtension, IValueConverter +{ + /// <summary> + /// The maximum number of converters that can be chained + /// </summary> + public const int MaxConverterCount = 8; + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class. + /// </summary> + public Chained() + : this(null, null, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter">The first value converter.</param> + public Chained(IValueConverter? converter) + : this(converter, null, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2) + : this(converter1, converter2, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3) + : this(converter1, converter2, converter3, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4) + : this(converter1, converter2, converter3, converter4, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, IValueConverter? converter5) + : this(converter1, converter2, converter3, converter4, converter5, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, IValueConverter? converter5, IValueConverter? converter6) + : this(converter1, converter2, converter3, converter4, converter5, converter6, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + /// <param name="converter7">The seventh value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, IValueConverter? converter5, IValueConverter? converter6, IValueConverter? converter7) + : this(converter1, converter2, converter3, converter4, converter5, converter6, converter7, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="Chained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + /// <param name="converter7">The seventh value converter.</param> + /// <param name="converter8">The eighth value converter.</param> + public Chained(IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, IValueConverter? converter5, IValueConverter? converter6, IValueConverter? converter7, IValueConverter? converter8) + { + Converter1 = converter1; + Converter2 = converter2; + Converter3 = converter3; + Converter4 = converter4; + Converter5 = converter5; + Converter6 = converter6; + Converter7 = converter7; + Converter8 = converter8; + } + + /// <summary> + /// Gets or sets the first converter to chain. + /// </summary> + public IValueConverter? Converter1 { get { return converters[0]; } set { converters[0] = value; } } + /// <summary> + /// Gets or sets the second converter to chain. + /// </summary> + public IValueConverter? Converter2 { get { return converters[1]; } set { converters[1] = value; } } + /// <summary> + /// Gets or sets the third converter to chain. + /// </summary> + public IValueConverter? Converter3 { get { return converters[2]; } set { converters[2] = value; } } + /// <summary> + /// Gets or sets the fourth converter to chain. + /// </summary> + public IValueConverter? Converter4 { get { return converters[3]; } set { converters[3] = value; } } + /// <summary> + /// Gets or sets the fifth converter to chain. + /// </summary> + public IValueConverter? Converter5 { get { return converters[4]; } set { converters[4] = value; } } + /// <summary> + /// Gets or sets the sixth converter to chain. + /// </summary> + public IValueConverter? Converter6 { get { return converters[5]; } set { converters[5] = value; } } + /// <summary> + /// Gets or sets the seventh converter to chain. + /// </summary> + public IValueConverter? Converter7 { get { return converters[6]; } set { converters[6] = value; } } + /// <summary> + /// Gets or sets the eighth converter to chain. + /// </summary> + public IValueConverter? Converter8 { get { return converters[7]; } set { converters[7] = value; } } + + /// <summary> + /// Gets or sets the parameter of the first converter to chain. + /// </summary> + public object? Parameter1 { get { return converterParameters[0]; } set { converterParameters[0] = value; } } + /// <summary> + /// Gets or sets the parameter of the second converter to chain. + /// </summary> + public object? Parameter2 { get { return converterParameters[1]; } set { converterParameters[1] = value; } } + /// <summary> + /// Gets or sets the parameter of the third converter to chain. + /// </summary> + public object? Parameter3 { get { return converterParameters[2]; } set { converterParameters[2] = value; } } + /// <summary> + /// Gets or sets the parameter of the fourth converter to chain. + /// </summary> + public object? Parameter4 { get { return converterParameters[3]; } set { converterParameters[3] = value; } } + /// <summary> + /// Gets or sets the parameter of the fifth converter to chain. + /// </summary> + public object? Parameter5 { get { return converterParameters[4]; } set { converterParameters[4] = value; } } + /// <summary> + /// Gets or sets the parameter of the sixth converter to chain. + /// </summary> + public object? Parameter6 { get { return converterParameters[5]; } set { converterParameters[5] = value; } } + /// <summary> + /// Gets or sets the parameter of the seventh converter to chain. + /// </summary> + public object? Parameter7 { get { return converterParameters[6]; } set { converterParameters[6] = value; } } + /// <summary> + /// Gets or sets the parameter of the eighth converter to chain. + /// </summary> + public object? Parameter8 { get { return converterParameters[7]; } set { converterParameters[7] = value; } } + + /// <summary> + /// Gets or sets the target type of the first converter to chain. + /// </summary> + public Type TargetType1 { get { return converterTargetType[0]; } set { converterTargetType[0] = value; } } + /// <summary> + /// Gets or sets the target type of the second converter to chain. + /// </summary> + public Type TargetType2 { get { return converterTargetType[1]; } set { converterTargetType[1] = value; } } + /// <summary> + /// Gets or sets the target type of the third converter to chain. + /// </summary> + public Type TargetType3 { get { return converterTargetType[2]; } set { converterTargetType[2] = value; } } + /// <summary> + /// Gets or sets the target type of the fourth converter to chain. + /// </summary> + public Type TargetType4 { get { return converterTargetType[3]; } set { converterTargetType[3] = value; } } + /// <summary> + /// Gets or sets the target type of the fifth converter to chain. + /// </summary> + public Type TargetType5 { get { return converterTargetType[4]; } set { converterTargetType[4] = value; } } + /// <summary> + /// Gets or sets the target type of the sixth converter to chain. + /// </summary> + public Type TargetType6 { get { return converterTargetType[5]; } set { converterTargetType[5] = value; } } + /// <summary> + /// Gets or sets the target type of the seventh converter to chain. + /// </summary> + public Type TargetType7 { get { return converterTargetType[6]; } set { converterTargetType[6] = value; } } + /// <summary> + /// Gets or sets the target type of the eighth converter to chain. + /// </summary> + public Type TargetType8 { get { return converterTargetType[7]; } set { converterTargetType[7] = value; } } + + private readonly IValueConverter?[] converters = new IValueConverter[MaxConverterCount]; + private readonly object?[] converterParameters = new object[MaxConverterCount]; + private readonly Type[] converterTargetType = new Type[MaxConverterCount]; + + /// <inheritdoc/> + public override object ProvideValue(IServiceProvider serviceProvider) + { + return this; + } + + /// <inheritdoc/> + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var output = value; + var conversionEnded = false; + + for (var i = 0; i < MaxConverterCount; ++i) + { + var input = output; + if (converters[i] == null) + { + conversionEnded = true; + continue; + } + + if (conversionEnded) + throw new InvalidOperationException($"Converter{i} is not null but previous Converter{i - 1} was null"); + + var type = converterTargetType[i] ?? (i == MaxConverterCount - 1 || converters[i + 1] == null ? targetType : typeof(object)); + output = converters[i]!.Convert(input, type, converterParameters[i], culture); + } + return output; + } + + /// <inheritdoc/> + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var output = value; + + var conversionStarted = false; + + for (var i = MaxConverterCount - 1; i >= 0; --i) + { + var input = output; + if (converters[i] == null) + { + if (!conversionStarted) + continue; + throw new InvalidOperationException($"Converter{i} is null but following Converter{i + 1} is not null"); + } + + conversionStarted = true; + + var type = converterTargetType[i] ?? (i == 0 ? targetType : typeof(object)); + output = converters[i]!.ConvertBack(input, type, converterParameters[i], culture); + } + return output; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/CompareNum.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/CompareNum.cs new file mode 100644 index 0000000000..874054e278 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/CompareNum.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// This converter will compare a given numeric value with a numeric value passed as parameter. +/// </summary> +public abstract class CompareNum<T> : OneWayValueConverter<T> + where T : OneWayValueConverter<T>, IValueConverter, new() +{ + /// <inheritdoc/> + public override object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var doubleValue = ConverterHelper.ConvertToDouble(value, culture); + var doubleParameter = ConverterHelper.ConvertToDouble(parameter, culture); + return Compare(doubleValue, doubleParameter); + } + + protected abstract bool Compare(double left, double right); +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is greater than the numeric parameter. +/// </summary> +public class IsGreater : CompareNum<IsGreater> +{ + protected override bool Compare(double left, double right) + { + return left > right; + } +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is lower than the numeric parameter. +/// </summary> +public class IsLower : CompareNum<IsLower> +{ + protected override bool Compare(double left, double right) + { + return left < right; + } +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is greater than or equal to the numeric parameter. +/// </summary> +public class IsGreaterOrEqual : CompareNum<IsGreaterOrEqual> +{ + protected override bool Compare(double left, double right) + { + return left >= right; + } +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is lower than or equal to the numeric parameter. +/// </summary> +public class IsLowerOrEqual : CompareNum<IsLowerOrEqual> +{ + protected override bool Compare(double left, double right) + { + return left <= right; + } +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is equal to the numeric parameter. +/// </summary> +public class IsEqual : CompareNum<IsEqual> +{ + protected override bool Compare(double left, double right) + { + return Math.Abs(left - right) <= double.Epsilon; + } +} + +/// <summary> +/// This converter will return <c>true</c> if the numeric value is different from the numeric parameter. +/// </summary> +public class IsDifferent : CompareNum<IsDifferent> +{ + protected override bool Compare(double left, double right) + { + return Math.Abs(left - right) > double.Epsilon; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ConverterHelper.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ConverterHelper.cs new file mode 100644 index 0000000000..4b549f4673 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ConverterHelper.cs @@ -0,0 +1,250 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.CompilerServices; +using Avalonia; +using Stride.Core.Mathematics; +using Stride.Core.Reflection; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// Helper class with similar methods than <see cref="Convert"/> but returns the default value of the expected type if value is <see cref="AvaloniaProperty.UnsetValue"/>. +/// </summary> +public static class ConverterHelper +{ + /// <summary> + /// Converts the given value to <see cref="bool"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="bool"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="bool"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ConvertToBoolean(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue && Convert.ToBoolean(value, culture); + } + + /// <summary> + /// Converts the given value to <see cref="char"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="char"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="char"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static char ConvertToChar(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue ? Convert.ToChar(Convert.ToUInt32(value), culture) : '\0'; + } + + /// <summary> + /// Converts the given value to <see cref="decimal"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="decimal"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="decimal"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static decimal ConvertToDecimal(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue ? Convert.ToDecimal(value, culture) : 0; + } + + /// <summary> + /// Converts the given value to <see cref="double"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="double"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="double"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double ConvertToDouble(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue ? Convert.ToDouble(value, culture) : 0; + } + + /// <summary> + /// Converts the given value to <see cref="int"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="int"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="int"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ConvertToInt32(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue ? Convert.ToInt32(value, culture) : 0; + } + + /// <summary> + /// Converts the given value to <see cref="TimeSpan"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="TimeSpan"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <returns>The value converted to <see cref="TimeSpan"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TimeSpan ConvertToTimeSpan(object? value) + { + return value != AvaloniaProperty.UnsetValue && value is not null ? (TimeSpan)value : TimeSpan.Zero; + } + + /// <summary> + /// Converts the given value to <see cref="AngleSingle"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="AngleSingle"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <returns>The value converted to <see cref="AngleSingle"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static AngleSingle ConvertToAngleSingle(object? value) + { + return value != AvaloniaProperty.UnsetValue && value is not null ? (AngleSingle)value : default; + } + + /// <summary> + /// Converts the given value to <see cref="string"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to <see cref="String.Empty"/>. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="string"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? ConvertToString(object? value, IFormatProvider culture) + { + return value != AvaloniaProperty.UnsetValue ? Convert.ToString(value, culture) : string.Empty; + } + + /// <summary> + /// Converts the given value to the given type. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the target type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="targetType">The target type.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to the target type.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static object? ChangeType(object? value, Type targetType, IFormatProvider culture) + { + // Retrieve the underlying type if the target type is a nullable. + return value != AvaloniaProperty.UnsetValue ? Convert.ChangeType(value, targetType, culture) : targetType.Default(); + } + + /// <summary> + /// Tries to convert the given value to <see cref="bool"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="bool"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool? TryConvertToBoolean(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToBoolean(value, culture) : null; + } + + /// <summary> + /// Tries to convert the given value to <see cref="char"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="char"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static char? TryConvertToChar(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToChar(value, culture) : null; + } + + /// <summary> + /// Tries to convert the given value to <see cref="decimal"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="decimal"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static decimal? TryConvertToDecimal(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToDecimal(value, culture) : null; + } + + /// <summary> + /// Tries to convert the given value to <see cref="double"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="double"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double? TryConvertToDouble(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToDouble(value, culture) : null; + } + + /// <summary> + /// Tries to convert the given value to <see cref="int"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="int"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int? TryConvertToInt32(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToInt32(value, culture) : null; + } + + /// <summary> + /// Converts the given value to <see cref="TimeSpan"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="TimeSpan"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <returns>The value converted to <see cref="TimeSpan"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TimeSpan? TryConvertToTimeSpan(object? value) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToTimeSpan(value) : null; + } + + /// <summary> + /// Converts the given value to <see cref="AngleSingle"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/>, it converts to the default value of the <see cref="AngleSingle"/> type. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <returns>The value converted to <see cref="AngleSingle"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static AngleSingle? TryConvertToAngleSingle(object? value) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToAngleSingle(value) : null; + } + + /// <summary> + /// Tries to convert the given value to <see cref="string"/>. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to <see cref="string"/> if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? TryConvertToString(object? value, IFormatProvider culture) + { + return value != null && value != AvaloniaProperty.UnsetValue ? ConvertToString(value, culture) : null; + } + + /// <summary> + /// Tries to convert the given value to the given type. + /// If the given value is <see cref="AvaloniaProperty.UnsetValue"/> or <c>Null</c>, then <c>Null</c> is returned. + /// </summary> + /// <param name="value">The value to convert.</param> + /// <param name="targetType">The target type.</param> + /// <param name="culture">The format provider to use for the conversion.</param> + /// <returns>The value converted to the target type if the conversion was possible, <c>Null</c> otherwise.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static object? TryChangeType(object? value, Type targetType, IFormatProvider culture) + { + // Retrieve the underlying type if the target type is a nullable. + targetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + return value != null && value != AvaloniaProperty.UnsetValue ? Convert.ChangeType(value, targetType, culture) : null; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/InvertBool.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/InvertBool.cs new file mode 100644 index 0000000000..f0f50e7ae6 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/InvertBool.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Stride.Core.Presentation.Avalonia.Internal; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +public sealed class InvertBool : ValueConverterBase<InvertBool> +{ + /// <inheritdoc/> + public override object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return (!ConverterHelper.ConvertToBoolean(value, culture)).Box(); + } + + /// <inheritdoc/> + public override object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiChained.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiChained.cs new file mode 100644 index 0000000000..9453a483e2 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiChained.cs @@ -0,0 +1,281 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// This converter can wrap an <see cref="IMultiValueConverter"/> and chain the result of this converter to up to <see cref="MaxConverterCount"/> +/// <see cref="IValueConverter"/> to further convert the resulting value. The first converter takes the value output by the <see cref="MultiConverter"/>, +/// and then each converter takes the previous converter output as input value. +/// The parameter and target type of each converter can also be specified. <see cref="IValueConverter.ConvertBack"/> is supported and converters are invoked backward. +/// </summary> +/// <remarks>This converter is also a <see cref="MarkupExtension"/>, which makes it convenient to use in XAML.</remarks> +public sealed class MultiChained : MarkupExtension, IMultiValueConverter +{ + private readonly Chained chainedConverter; + + /// <summary> + /// The maximum number of converters that can be chained + /// </summary> + public const int MaxConverterCount = Chained.MaxConverterCount; + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + public MultiChained(IMultiValueConverter multiConverter) + : this(multiConverter, null, null, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter">The first value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter) + : this(multiConverter, converter, null, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2) + : this(multiConverter, converter1, converter2, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3) + : this(multiConverter, converter1, converter2, converter3, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4) + : this(multiConverter, converter1, converter2, converter3, converter4, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, + IValueConverter? converter5) + : this(multiConverter, converter1, converter2, converter3, converter4, converter5, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, + IValueConverter? converter5, IValueConverter? converter6) + : this(multiConverter, converter1, converter2, converter3, converter4, converter5, converter6, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + /// <param name="converter7">The seventh value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, + IValueConverter? converter5, IValueConverter? converter6, IValueConverter? converter7) + : this(multiConverter, converter1, converter2, converter3, converter4, converter5, converter6, converter7, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiChained"/> class with the given instances of <see cref="IValueConverter"/>. + /// </summary> + /// <param name="multiConverter">The multi-value converter.</param> + /// <param name="converter1">The first value converter.</param> + /// <param name="converter2">The second value converter.</param> + /// <param name="converter3">The third value converter.</param> + /// <param name="converter4">The fourth value converter.</param> + /// <param name="converter5">The fifth value converter.</param> + /// <param name="converter6">The sixth value converter.</param> + /// <param name="converter7">The seventh value converter.</param> + /// <param name="converter8">The eighth value converter.</param> + public MultiChained(IMultiValueConverter multiConverter, IValueConverter? converter1, IValueConverter? converter2, IValueConverter? converter3, IValueConverter? converter4, + IValueConverter? converter5, IValueConverter? converter6, IValueConverter? converter7, IValueConverter? converter8) + { + chainedConverter = new Chained(); + MultiConverter = multiConverter; + Converter1 = converter1; + Converter2 = converter2; + Converter3 = converter3; + Converter4 = converter4; + Converter5 = converter5; + Converter6 = converter6; + Converter7 = converter7; + Converter8 = converter8; + } + + /// <summary> + /// Gets or sets the multi value converter, when this converter is used as an <see cref="IMultiValueConverter"/> + /// </summary> + public IMultiValueConverter MultiConverter { get; set; } + /// <summary> + /// Gets or sets the parameter of the multi value converter, when this converter is used as an <see cref="IMultiValueConverter"/>. + /// </summary> + public object? MultiConverterParameter { get; set; } + /// <summary> + /// Gets or sets the target type of the multi value converter, when this converter is used as an <see cref="IMultiValueConverter"/>. + /// </summary> + public Type? MultiConverterTargetType { get; set; } + + /// <summary> + /// Gets or sets the first converter to chain. + /// </summary> + public IValueConverter? Converter1 { get { return chainedConverter.Converter1; } set { chainedConverter.Converter1 = value; } } + /// <summary> + /// Gets or sets the second converter to chain. + /// </summary> + public IValueConverter? Converter2 { get { return chainedConverter.Converter2; } set { chainedConverter.Converter2 = value; } } + /// <summary> + /// Gets or sets the third converter to chain. + /// </summary> + public IValueConverter? Converter3 { get { return chainedConverter.Converter3; } set { chainedConverter.Converter3 = value; } } + /// <summary> + /// Gets or sets the fourth converter to chain. + /// </summary> + public IValueConverter? Converter4 { get { return chainedConverter.Converter4; } set { chainedConverter.Converter4 = value; } } + /// <summary> + /// Gets or sets the fifth converter to chain. + /// </summary> + public IValueConverter? Converter5 { get { return chainedConverter.Converter5; } set { chainedConverter.Converter5 = value; } } + /// <summary> + /// Gets or sets the sixth converter to chain. + /// </summary> + public IValueConverter? Converter6 { get { return chainedConverter.Converter6; } set { chainedConverter.Converter6 = value; } } + /// <summary> + /// Gets or sets the seventh converter to chain. + /// </summary> + public IValueConverter? Converter7 { get { return chainedConverter.Converter7; } set { chainedConverter.Converter7 = value; } } + /// <summary> + /// Gets or sets the eighth converter to chain. + /// </summary> + public IValueConverter? Converter8 { get { return chainedConverter.Converter8; } set { chainedConverter.Converter8 = value; } } + + /// <summary> + /// Gets or sets the parameter of the first converter to chain. + /// </summary> + public object? Parameter1 { get { return chainedConverter.Parameter1; } set { chainedConverter.Parameter1 = value; } } + /// <summary> + /// Gets or sets the parameter of the second converter to chain. + /// </summary> + public object? Parameter2 { get { return chainedConverter.Parameter2; } set { chainedConverter.Parameter2 = value; } } + /// <summary> + /// Gets or sets the parameter of the third converter to chain. + /// </summary> + public object? Parameter3 { get { return chainedConverter.Parameter3; } set { chainedConverter.Parameter3 = value; } } + /// <summary> + /// Gets or sets the parameter of the fourth converter to chain. + /// </summary> + public object? Parameter4 { get { return chainedConverter.Parameter4; } set { chainedConverter.Parameter4 = value; } } + /// <summary> + /// Gets or sets the parameter of the fifth converter to chain. + /// </summary> + public object? Parameter5 { get { return chainedConverter.Parameter5; } set { chainedConverter.Parameter5 = value; } } + /// <summary> + /// Gets or sets the parameter of the sixth converter to chain. + /// </summary> + public object? Parameter6 { get { return chainedConverter.Parameter6; } set { chainedConverter.Parameter6 = value; } } + /// <summary> + /// Gets or sets the parameter of the seventh converter to chain. + /// </summary> + public object? Parameter7 { get { return chainedConverter.Parameter7; } set { chainedConverter.Parameter7 = value; } } + /// <summary> + /// Gets or sets the parameter of the eighth converter to chain. + /// </summary> + public object? Parameter8 { get { return chainedConverter.Parameter8; } set { chainedConverter.Parameter8 = value; } } + + /// <summary> + /// Gets or sets the target type of the first converter to chain. + /// </summary> + public Type TargetType1 { get { return chainedConverter.TargetType1; } set { chainedConverter.TargetType1 = value; } } + /// <summary> + /// Gets or sets the target type of the second converter to chain. + /// </summary> + public Type TargetType2 { get { return chainedConverter.TargetType2; } set { chainedConverter.TargetType2 = value; } } + /// <summary> + /// Gets or sets the target type of the third converter to chain. + /// </summary> + public Type TargetType3 { get { return chainedConverter.TargetType3; } set { chainedConverter.TargetType3 = value; } } + /// <summary> + /// Gets or sets the target type of the fourth converter to chain. + /// </summary> + public Type TargetType4 { get { return chainedConverter.TargetType4; } set { chainedConverter.TargetType4 = value; } } + /// <summary> + /// Gets or sets the target type of the fifth converter to chain. + /// </summary> + public Type TargetType5 { get { return chainedConverter.TargetType5; } set { chainedConverter.TargetType5 = value; } } + /// <summary> + /// Gets or sets the target type of the sixth converter to chain. + /// </summary> + public Type TargetType6 { get { return chainedConverter.TargetType6; } set { chainedConverter.TargetType6 = value; } } + /// <summary> + /// Gets or sets the target type of the seventh converter to chain. + /// </summary> + public Type TargetType7 { get { return chainedConverter.TargetType7; } set { chainedConverter.TargetType7 = value; } } + /// <summary> + /// Gets or sets the target type of the eighth converter to chain. + /// </summary> + public Type TargetType8 { get { return chainedConverter.TargetType8; } set { chainedConverter.TargetType8 = value; } } + + /// <inheritdoc/> + public override object ProvideValue(IServiceProvider serviceProvider) + { + return this; + } + + /// <inheritdoc/> + public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture) + { + if (MultiConverter == null) throw new InvalidOperationException("No multi value converter has been set."); + var result = MultiConverter.Convert(values, MultiConverterTargetType ?? typeof(object), MultiConverterParameter, culture); + return chainedConverter.Convert(result, targetType, parameter, culture); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiValueConverterBase.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiValueConverterBase.cs new file mode 100644 index 0000000000..fb91e6a594 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/MultiValueConverterBase.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// An abstract class for implementations of <see cref="IMultiValueConverter"/> that supports markup extensions. +/// </summary> +/// <typeparam name="T">The type of <see cref="IMultiValueConverter"/> being implemented.</typeparam> +public abstract class MultiValueConverterBase<T> : MarkupExtension, IMultiValueConverter + where T : MultiValueConverterBase<T>, IMultiValueConverter, new() +{ + private static T? valueConverterInstance; + + /// <summary> + /// Initializes a new instance of the <see cref="ValueConverterBase{T}"/> class. + /// </summary> + /// <exception cref="InvalidOperationException">The generic argument does not match the type of the implementation of this class.</exception> + protected MultiValueConverterBase() + { + if (GetType() != typeof(T)) throw new InvalidOperationException("The generic argument of this class must be the type being implemented."); + } + + /// <inheritdoc/> + public abstract object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture); + + /// <inheritdoc/> + public sealed override IMultiValueConverter ProvideValue(IServiceProvider serviceProvider) + { + return valueConverterInstance ??= new T(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ObjectToBool.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ObjectToBool.cs new file mode 100644 index 0000000000..41628e07df --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ObjectToBool.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Stride.Core.Presentation.Avalonia.Internal; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// This converter will convert an object to a boolean value, returning <c>false</c> if the object is equal to null, <c>true</c> otherwise. +/// </summary> +/// <remarks>Value types are always non-null and therefore always returns true.</remarks> +public sealed class ObjectToBool : OneWayValueConverter<ObjectToBool> +{ + /// <inheritdoc/> + public override object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return (value is not null).Box(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/OneWayValueConverter.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/OneWayValueConverter.cs new file mode 100644 index 0000000000..2624512284 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/OneWayValueConverter.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// An abstract implementation of <see cref="ValueConverterBase{T}"/> that does not support <see cref="ConvertBack"/>. +/// Invoking <see cref="ConvertBack"/> on this value converter will throw a <see cref="NotSupportedException"/>. +/// </summary> +/// <typeparam name="T">The type of <see cref="IValueConverter"/> being implemented.</typeparam> +public abstract class OneWayValueConverter<T> : ValueConverterBase<T> + where T : OneWayValueConverter<T>, IValueConverter, new() +{ + /// <inheritdoc/> + public sealed override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException("ConvertBack is not supported with this ValueConverter."); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ValueConverterBase.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ValueConverterBase.cs new file mode 100644 index 0000000000..53ae33a04a --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/ValueConverterBase.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// <summary> +/// An abstract class for implementations of <see cref="IValueConverter"/> that supports markup extensions. +/// </summary> +/// <typeparam name="T">The type of <see cref="IValueConverter"/> being implemented.</typeparam> +public abstract class ValueConverterBase<T> : MarkupExtension, IValueConverter + where T : ValueConverterBase<T>, new() +{ + private static T? valueConverterInstance; + + /// <summary> + /// Initializes a new instance of the <see cref="ValueConverterBase{T}"/> class. + /// </summary> + /// <exception cref="InvalidOperationException">The generic argument does not match the type of the implementation of this class.</exception> + protected ValueConverterBase() + { + if (GetType() != typeof(T)) throw new InvalidOperationException("The generic argument of this class must be the type being implemented."); + } + + /// <inheritdoc/> + public abstract object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture); + + /// <inheritdoc/> + public abstract object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture); + + /// <inheritdoc/> + public sealed override IValueConverter ProvideValue(IServiceProvider serviceProvider) + { + return valueConverterInstance ??= new T(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Internal/BooleanBoxes.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Internal/BooleanBoxes.cs new file mode 100644 index 0000000000..ac73466d6a --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Internal/BooleanBoxes.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.CompilerServices; + +namespace Stride.Core.Presentation.Avalonia.Internal; + +internal static class BooleanBoxes +{ + /// <summary> + /// An object representing the value <c>false</c>. + /// </summary> + internal static readonly object FalseBox = false; + /// <summary> + /// An object representing the value <c>true</c>. + /// </summary> + internal static readonly object TrueBox = true; + + /// <summary> + /// Returns an object representing the provided <see cref="bool"/> <paramref name="value"/>. + /// </summary> + /// <param name="value"></param> + /// <returns>A boxed <see cref="bool"/> equivalent to the provided <paramref name="value"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static object Box(this bool value) + { + return value ? TrueBox : FalseBox; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/DoubleExtension.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/DoubleExtension.cs new file mode 100644 index 0000000000..358f62d584 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/DoubleExtension.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Avalonia.Markup.Xaml; +using Avalonia.Metadata; + +namespace Stride.Core.Presentation.Avalonia.MarkupExtensions; + +public sealed class DoubleExtension : MarkupExtension +{ + [Content] + public double Value { get; set; } + + public DoubleExtension() + { + Value = 0.0; + } + + public DoubleExtension(object value) + { + Value = Convert.ToDouble(value, CultureInfo.InvariantCulture); + } + + /// <inheritdoc/> + public override object ProvideValue(IServiceProvider serviceProvider) + { + return Value; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/MultiBindingExtension.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/MultiBindingExtension.cs new file mode 100644 index 0000000000..e1e81ec1c3 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/MarkupExtensions/MultiBindingExtension.cs @@ -0,0 +1,130 @@ +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Metadata; + +namespace Stride.Core.Presentation.Avalonia.MarkupExtensions; + +/// <summary> +/// This class augments the <see cref="MultiBinding"/> by providing constructors that allows construction using markup extension. +/// </summary> +public sealed class MultiBindingExtension +{ + private MultiBinding? cachedBinding; + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2) + : this(binding1, binding2, null, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2, BindingBase binding3) + : this(binding1, binding2, binding3, null, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + /// <param name="binding4">The fourth binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2, BindingBase binding3, BindingBase binding4) + : this(binding1, binding2, binding3, binding4, null, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + /// <param name="binding4">The fourth binding.</param> + /// <param name="binding5">The fifth binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2, BindingBase binding3, BindingBase binding4, BindingBase binding5) + : this(binding1, binding2, binding3, binding4, binding5, null, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + /// <param name="binding4">The fourth binding.</param> + /// <param name="binding5">The fifth binding.</param> + /// <param name="binding6">The sixth binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2, BindingBase binding3, BindingBase binding4, BindingBase binding5, BindingBase binding6) + : this(binding1, binding2, binding3, binding4, binding5, binding6, null, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + /// <param name="binding4">The fourth binding.</param> + /// <param name="binding5">The fifth binding.</param> + /// <param name="binding6">The sixth binding.</param> + /// <param name="binding7">The seventh binding.</param> + public MultiBindingExtension(BindingBase binding1, BindingBase binding2, BindingBase binding3, BindingBase binding4, BindingBase binding5, BindingBase binding6, BindingBase binding7) + : this(binding1, binding2, binding3, binding4, binding5, binding6, binding7, null) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="MultiBindingExtension"/> class. + /// </summary> + /// <param name="binding1">The first binding.</param> + /// <param name="binding2">The second binding.</param> + /// <param name="binding3">The third binding.</param> + /// <param name="binding4">The fourth binding.</param> + /// <param name="binding5">The fifth binding.</param> + /// <param name="binding6">The sixth binding.</param> + /// <param name="binding7">The seventh binding.</param> + /// <param name="binding8">The eighth binding.</param> + public MultiBindingExtension(BindingBase? binding1, BindingBase? binding2, BindingBase? binding3, BindingBase? binding4, BindingBase? binding5, BindingBase? binding6, BindingBase? binding7, BindingBase? binding8) + : base() + { + if (binding1 != null) Bindings.Add(binding1); + if (binding2 != null) Bindings.Add(binding2); + if (binding3 != null) Bindings.Add(binding3); + if (binding4 != null) Bindings.Add(binding4); + if (binding5 != null) Bindings.Add(binding5); + if (binding6 != null) Bindings.Add(binding6); + if (binding7 != null) Bindings.Add(binding7); + if (binding8 != null) Bindings.Add(binding8); + } + + [Content, AssignBinding] + public IList<BindingBase> Bindings { get; init; } = []; + + public IMultiValueConverter? Converter { get; init; } + + public MultiBinding ProvideTypedValue() + { + if (cachedBinding is not null) + return cachedBinding; + + cachedBinding = new MultiBinding { Converter = Converter }; + + foreach (var binding in Bindings) + cachedBinding.Bindings.Add(binding); + + return cachedBinding; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Properties/AssemblyInfo.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..dcda1d7b6e --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Metadata; + +[assembly: XmlnsPrefix("http://schemas.stride3d.net/xaml/presentation", "sd")] +[assembly: XmlnsDefinition("http://schemas.stride3d.net/xaml/presentation", "Stride.Core.Presentation.Avalonia")] +[assembly: XmlnsDefinition("http://schemas.stride3d.net/xaml/presentation", "Stride.Core.Presentation.Avalonia.Behaviors")] +[assembly: XmlnsDefinition("http://schemas.stride3d.net/xaml/presentation", "Stride.Core.Presentation.Avalonia.Converters")] +[assembly: XmlnsDefinition("http://schemas.stride3d.net/xaml/presentation", "Stride.Core.Presentation.Avalonia.MarkupExtensions")] diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/README.md b/sources/presentation/Stride.Core.Presentation.Avalonia/README.md new file mode 100644 index 0000000000..188e1495e1 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/README.md @@ -0,0 +1,20 @@ +# Stride.Core.Presentation.Avalonia + +This project contains helpers for views. + +## Dependencies + +* It can only references *Core* libraries. +* It is a specific implementation using Avalonia. +* It will likely only reference `Stride.Core.Presentation`. + +## Implementations + +Value converters, markup extensions and custom controls are here. + +## Notes + +* The goal is to be able to share that library with any application that wants to work with Stride core. +* It can theoretically be used for another engine that would only be based on the core libraries. + So it has no dependency on the Stride runtime. + diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DialogService.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DialogService.cs new file mode 100644 index 0000000000..aaa0f6af2e --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DialogService.cs @@ -0,0 +1,167 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Stride.Core.IO; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.Avalonia.Windows; +using Stride.Core.Presentation.Windows; + +namespace Stride.Core.Presentation.Avalonia.Services; + +// Note: this class is shared with the Launcher. Beware before adding new dependencies. +public class DialogService : IDialogService +{ + public DialogService(IDispatcherService dispatcher) + { + Dispatcher = dispatcher; + } + + public string ApplicationName { get; init; } = string.Empty; + + public bool HasMainWindow => MainWindow != null; + + public static Window? MainWindow => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + + protected IDispatcherService Dispatcher { get; } + + protected IStorageProvider? StorageProvider => MainWindow?.StorageProvider; + + public void Exit(int exitCode = 0) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + if (desktopLifetime.ShutdownMode == ShutdownMode.OnMainWindowClose && desktopLifetime.MainWindow is { } mainWindow) + { + mainWindow.Close(); + } + else + { + desktopLifetime.TryShutdown(exitCode); + } + } + else if (Application.Current?.ApplicationLifetime is IControlledApplicationLifetime controlledLifetime) + { + controlledLifetime.Shutdown(); + } + else + { + Environment.Exit(exitCode); + } + } + + public async Task<UFile?> OpenFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null) + { + if (StorageProvider is null) return null; + + return await Dispatcher.InvokeTask(async () => + { + var initialLocation = await StorageProvider.TryGetFolderFromPathAsync(initialPath); + var storageFiles = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + AllowMultiple = false, + FileTypeFilter = filters?.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Patterns }).ToList(), + SuggestedStartLocation = initialLocation, + }); + + var storageFile = storageFiles?.Count > 0 ? storageFiles[0] : null; + var path = storageFile?.TryGetLocalPath(); + if (string.IsNullOrEmpty(path)) return null; + + return path; + }); + } + + public async Task<IReadOnlyList<UFile>> OpenMultipleFilesPickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null) + { + if (StorageProvider is null) return Array.Empty<UFile>(); + + return await Dispatcher.InvokeTask(async () => + { + var initialLocation = await StorageProvider.TryGetFolderFromPathAsync(initialPath); + var storageFiles = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + AllowMultiple = false, + FileTypeFilter = filters?.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Patterns }).ToList(), + SuggestedStartLocation = initialLocation, + }); + + var files = new List<UFile>(storageFiles.Count); + foreach (var storageFile in storageFiles) + { + var path = storageFile?.TryGetLocalPath(); + if (string.IsNullOrEmpty(path)) continue; + + files.Add(path); + } + + return files; + }); + } + + public async Task<UDirectory?> OpenFolderPickerAsync(UDirectory? initialPath = null) + { + if (StorageProvider is null) return null; + + return await Dispatcher.InvokeTask(async () => + { + var initialLocation = await StorageProvider.TryGetFolderFromPathAsync(initialPath); + var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + AllowMultiple = false, + SuggestedStartLocation = initialLocation, + }); + + var folder = folders?.Count > 0 ? folders[0] : null; + var path = folder?.TryGetLocalPath(); + if (string.IsNullOrEmpty(path)) return null; + + return path; + }); + } + + public async Task<UFile?> SaveFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList<FilePickerFilter>? filters = null, string? defaultExtension = null, string? defaultFileName = null) + { + if (StorageProvider is null) return null; + + return await Dispatcher.InvokeTask(async () => + { + var initialLocation = await StorageProvider.TryGetFolderFromPathAsync(initialPath); + var storageFile = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + DefaultExtension = defaultExtension, + FileTypeChoices = filters?.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Patterns }).ToList(), + ShowOverwritePrompt = true, + SuggestedFileName = defaultFileName, + SuggestedStartLocation = initialLocation + }); + var path = storageFile?.TryGetLocalPath(); + if (string.IsNullOrEmpty(path)) return null; + + return path; + }); + } + + public async Task<CheckedMessageBoxResult> CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, MessageBoxButton buttons, MessageBoxImage image) + { + return await Dispatcher.InvokeTask(() => CheckedMessageBox.ShowAsync(ApplicationName, message, isChecked, checkboxMessage, IDialogService.GetButtons(buttons), image, MainWindow)); + } + + public async Task<CheckedMessageBoxResult> CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None) + { + return await Dispatcher.InvokeTask(() => CheckedMessageBox.ShowAsync(ApplicationName, message, isChecked, checkboxMessage, buttons, image, MainWindow)); + } + + public async Task<MessageBoxResult> MessageBoxAsync(string message, MessageBoxButton buttons, MessageBoxImage image) + { + return (MessageBoxResult)await Dispatcher.InvokeTask(() => MessageBox.ShowAsync(ApplicationName, message, IDialogService.GetButtons(buttons), image, MainWindow)); + } + + public async Task<int> MessageBoxAsync(string message, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None) + { + return await Dispatcher.InvokeTask(() => MessageBox.ShowAsync(ApplicationName, message, buttons, image, MainWindow)); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DispatcherService.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DispatcherService.cs new file mode 100644 index 0000000000..98d8295b55 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/DispatcherService.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia.Threading; +using Stride.Core.Presentation.Services; + +namespace Stride.Core.Presentation.Avalonia.Services; + +/// <summary> +/// This class is the implementation of the <see cref="IDispatcherService"/> interface for Avalonia. +/// </summary> +// Note: this class is shared with the Launcher. Beware before adding new dependencies. +public sealed class DispatcherService : IDispatcherService +{ + private readonly Dispatcher dispatcher; + + /// <summary> + /// Creates a new instance of the <see cref="DispatcherService"/> class using the dispatcher of the current thread. + /// </summary> + /// <returns></returns> + public static DispatcherService Create() + { + return new DispatcherService(Dispatcher.UIThread); + } + + /// <summary> + /// Initializes a new instance of the <see cref="DispatcherService"/> class using the associated dispatcher. + /// </summary> + /// <param name="dispatcher">The dispatcher to use for this instance of <see cref="DispatcherService"/>.</param> + public DispatcherService(Dispatcher dispatcher) + { + this.dispatcher = dispatcher; + } + + /// <inheritdoc/> + public void Invoke(Action callback) + { + if (CheckAccess()) + callback(); + else + dispatcher.Invoke(callback); + } + + /// <inheritdoc/> + public TResult Invoke<TResult>(Func<TResult> callback) + { + return CheckAccess() ? callback() : dispatcher.Invoke(callback); + } + + /// <inheritdoc/> + public Task InvokeAsync(Action callback, CancellationToken token = default) + { + return dispatcher.InvokeAsync(callback, default, token).GetTask(); + } + + /// <inheritdoc/> + public Task LowPriorityInvokeAsync(Action callback, CancellationToken token = default) + { + return dispatcher.InvokeAsync(callback, DispatcherPriority.ApplicationIdle, token).GetTask(); + } + + /// <inheritdoc/> + public Task<TResult> InvokeAsync<TResult>(Func<TResult> callback, CancellationToken token = default) + { + return dispatcher.InvokeAsync(callback, default, token).GetTask(); + } + + /// <inheritdoc/> + public Task InvokeTask(Func<Task> task, CancellationToken token = default) + { + return InvokeTask(dispatcher, task, token); + } + + /// <inheritdoc/> + public Task<TResult> InvokeTask<TResult>(Func<Task<TResult>> task, CancellationToken token = default) + { + return InvokeTask(dispatcher, task, token); + } + + public static Task InvokeTask(Dispatcher dispatcher, Func<Task> task, CancellationToken token = default) + { + return dispatcher.InvokeAsync(task, default, token).GetTask().Unwrap(); + } + + public static Task<TResult> InvokeTask<TResult>(Dispatcher dispatcher, Func<Task<TResult>> task, CancellationToken token = default) + { + return dispatcher.InvokeAsync(task, default, token).GetTask().Unwrap(); + } + + /// <inheritdoc/> + public bool CheckAccess() + { + return dispatcher.CheckAccess(); + } + + /// <inheritdoc/> + public void EnsureAccess(bool inDispatcherThread = true) + { + if (inDispatcherThread && !CheckAccess()) + throw new InvalidOperationException("The current thread was expected to be the dispatcher thread."); + if (!inDispatcherThread && CheckAccess()) + throw new InvalidOperationException("The current thread was expected to be different from the dispatcher thread."); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Services/OperatingSystemHelper.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/OperatingSystemHelper.cs new file mode 100644 index 0000000000..a7b288dca1 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Services/OperatingSystemHelper.cs @@ -0,0 +1,8 @@ +namespace Stride.Core.Presentation.Avalonia.Services; + +public static class OperatingSystemHelper +{ + public static readonly bool IsWindows = OperatingSystem.IsWindows(); + public static readonly bool IsLinux = OperatingSystem.IsLinux(); + public static readonly bool IsMacOS = OperatingSystem.IsMacOS(); +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Stride.Core.Presentation.Avalonia.csproj b/sources/presentation/Stride.Core.Presentation.Avalonia/Stride.Core.Presentation.Avalonia.csproj new file mode 100644 index 0000000000..6d90e85ec1 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Stride.Core.Presentation.Avalonia.csproj @@ -0,0 +1,30 @@ +<Project> + <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), 'Directory.Build.props'))/sdk/Stride.Build.Sdk.Editor/Sdk/Sdk.props" /> + <PropertyGroup> + <TargetFramework>$(StrideXplatEditorTargetFramework)</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <LangVersion>latest</LangVersion> + <Nullable>enable</Nullable> + <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\..\shared\SharedAssemblyInfo.cs"> + <Link>Properties\SharedAssemblyInfo.cs</Link> + </Compile> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Avalonia" /> + <!--Condition below is needed to remove AvaloniaUI.DiagnosticsSupport package from build output in Release configuration.--> + <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" /> + <PackageReference Include="MarkView.Avalonia" /> + <PackageReference Include="Xaml.Behaviors.Interactions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\core\Stride.Core.Translation\Stride.Core.Translation.csproj" /> + <ProjectReference Include="..\..\presentation\Stride.Core.Presentation\Stride.Core.Presentation.csproj" /> + </ItemGroup> + <Import Project="$(StrideRoot)sources/sdk/Stride.Build.Sdk.Editor/Sdk/Sdk.targets" /> +</Project> diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml new file mode 100644 index 0000000000..1a6cdc283e --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml @@ -0,0 +1,69 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mv="using:MarkView.Avalonia" + xmlns:cvt="using:Stride.Core.Presentation.Avalonia.Converters" + xmlns:local="using:Stride.Core.Presentation.Windows" + xmlns:windows="using:Stride.Core.Presentation.Avalonia.Windows" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + MinHeight="120" MinWidth="320" MaxHeight="768" + SizeToContent="WidthAndHeight" CanResize="False" + WindowStartupLocation="CenterOwner" + Padding="8,0" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Center" + x:Class="Stride.Core.Presentation.Avalonia.Windows.CheckedMessageBox"> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceInclude Source="avares://Stride.Core.Presentation.Avalonia/Windows/GeometryResources.axaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Window.Resources> + <Window.ContentTemplate> + <DataTemplate> + <DockPanel LastChildFill="True" Margin="8"> + <!-- buttons --> + <ItemsControl DockPanel.Dock="Bottom" + HorizontalAlignment="Right" + ItemsSource="{Binding $parent[windows:MessageBox].ButtonsSource}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <UniformGrid Rows="1" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type local:DialogButtonInfo}"> + <Button Margin="4,0" Padding="16,4" MinWidth="80" + HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + Command="{Binding $parent[windows:MessageBox].ButtonCommand}" + CommandParameter="{Binding Result}" + Content="{Binding Content}" + IsCancel="{Binding IsCancel}" IsDefault="{Binding IsDefault}" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <!-- content --> + <ContentPresenter Content="{Binding $parent[ContentControl].Content}"> + <ContentPresenter.ContentTemplate> + <DataTemplate> + <DockPanel LastChildFill="True"> + <CheckBox DockPanel.Dock="Bottom" + Content="{Binding $parent[windows:CheckedMessageBox].CheckedMessage}" + IsChecked="{Binding $parent[windows:CheckedMessageBox].IsChecked}" + HorizontalAlignment="Left" VerticalAlignment="Bottom" + Margin="0,12,0,4" /> + <Path DockPanel.Dock="Left" + HorizontalAlignment="Left" VerticalAlignment="Top" + Height="32" Width="32" Margin="4" + Stretch="Uniform" Fill="{DynamicResource SystemAccentColor}" + Data="{Binding $parent[windows:MessageBox].Geometry}" + IsVisible="{Binding $parent[windows:MessageBox].Geometry, Converter={cvt:ObjectToBool}}" /> + <mv:MarkdownViewer Markdown="{ReflectionBinding}" VerticalAlignment="Center" /> + </DockPanel> + </DataTemplate> + </ContentPresenter.ContentTemplate> + </ContentPresenter> + </DockPanel> + </DataTemplate> + </Window.ContentTemplate> +</Window> diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml.cs new file mode 100644 index 0000000000..067a957729 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/CheckedMessageBox.axaml.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.Windows; + +namespace Stride.Core.Presentation.Avalonia.Windows; + +public partial class CheckedMessageBox : MessageBox +{ + /// <summary> + /// Identifies the <see cref="CheckedMessage"/> dependency property. + /// </summary> + public static readonly StyledProperty<string> CheckedMessageProperty = + AvaloniaProperty.Register<CheckedMessageBox, string>(nameof(CheckedMessage)); + + /// <summary> + /// Identifies the <see cref="IsCheckedProperty"/> dependency property. + /// </summary> + public static readonly StyledProperty<bool> IsCheckedProperty = + AvaloniaProperty.Register<CheckedMessageBox, bool>(nameof(IsChecked)); + + public string CheckedMessage + { + get { return (string)GetValue(CheckedMessageProperty); } + set { SetValue(CheckedMessageProperty, value); } + } + + public bool? IsChecked + { + get { return (bool?)GetValue(IsCheckedProperty); } + set { SetValue(IsCheckedProperty, value); } + } + + public static async Task<CheckedMessageBoxResult> ShowAsync(string caption, string message, bool? isChecked, string checkedMessage, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None, Window? owner = null) + { + var messageBox = new CheckedMessageBox + { + ButtonsSource = buttons, + Content = message, + Title = caption, + CheckedMessage = checkedMessage, + IsChecked = isChecked, + }; + SetGeometry(messageBox, image); + SetKeyBindings(messageBox, buttons); + if (owner is not null) + { + await messageBox.ShowDialog(owner); + } + else + { + var tcs = new TaskCompletionSource(); + messageBox.Closed += (sender, args) => + { + tcs.SetResult(); + }; + messageBox.Show(); + await tcs.Task; + } + var result = messageBox.ButtonResult; + return new CheckedMessageBoxResult(result, messageBox.IsChecked); + } + +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/GeometryResources.axaml b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/GeometryResources.axaml new file mode 100644 index 0000000000..4783a9b481 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/GeometryResources.axaml @@ -0,0 +1,9 @@ +<ResourceDictionary xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + <!-- Fluent Icons for Avalonia (https://avaloniaui.github.io/icons.html) --> + <Geometry x:Key="GeometryError">M12.45 2.15C14.992 4.05652 17.5866 5 20.25 5C20.6642 5 21 5.33579 21 5.75V11C21 16.0012 18.0424 19.6757 12.2749 21.9478C12.0982 22.0174 11.9018 22.0174 11.7251 21.9478C5.95756 19.6757 3 16.0012 3 11V5.75C3 5.33579 3.33579 5 3.75 5C6.41341 5 9.00797 4.05652 11.55 2.15C11.8167 1.95 12.1833 1.95 12.45 2.15ZM12 3.67782C9.58084 5.38829 7.07735 6.32585 4.5 6.47793V11C4.5 15.2556 6.95337 18.3789 12 20.4419C17.0466 18.3789 19.5 15.2556 19.5 11V6.47793C16.9227 6.32585 14.4192 5.38829 12 3.67782ZM12 16C12.4142 16 12.75 16.3358 12.75 16.75C12.75 17.1642 12.4142 17.5 12 17.5C11.5858 17.5 11.25 17.1642 11.25 16.75C11.25 16.3358 11.5858 16 12 16ZM12 7.00356C12.3797 7.00356 12.6935 7.28572 12.7432 7.65179L12.75 7.75356V14.2523C12.75 14.6665 12.4142 15.0023 12 15.0023C11.6203 15.0023 11.3065 14.7201 11.2568 14.3541L11.25 14.2523V7.75356C11.25 7.33935 11.5858 7.00356 12 7.00356Z</Geometry> + <Geometry x:Key="GeometryInformation">M14,2 C20.6274,2 26,7.37258 26,14 C26,20.6274 20.6274,26 14,26 C7.37258,26 2,20.6274 2,14 C2,7.37258 7.37258,2 14,2 Z M14,3.5 C8.20101,3.5 3.5,8.20101 3.5,14 C3.5,19.799 8.20101,24.5 14,24.5 C19.799,24.5 24.5,19.799 24.5,14 C24.5,8.20101 19.799,3.5 14,3.5 Z M14,11 C14.3796833,11 14.6934889,11.2821653 14.7431531,11.6482323 L14.75,11.75 L14.75,19.25 C14.75,19.6642 14.4142,20 14,20 C13.6203167,20 13.3065111,19.7178347 13.2568469,19.3517677 L13.25,19.25 L13.25,11.75 C13.25,11.3358 13.5858,11 14,11 Z M14,7 C14.5523,7 15,7.44772 15,8 C15,8.55228 14.5523,9 14,9 C13.4477,9 13,8.55228 13,8 C13,7.44772 13.4477,7 14,7 Z</Geometry> + <Geometry x:Key="GeometryQuestion">M24 4C35.0457 4 44 12.9543 44 24C44 35.0457 35.0457 44 24 44C12.9543 44 4 35.0457 4 24C4 12.9543 12.9543 4 24 4ZM24 6.5C14.335 6.5 6.5 14.335 6.5 24C6.5 33.665 14.335 41.5 24 41.5C33.665 41.5 41.5 33.665 41.5 24C41.5 14.335 33.665 6.5 24 6.5ZM24.25 32C25.0784 32 25.75 32.6716 25.75 33.5C25.75 34.3284 25.0784 35 24.25 35C23.4216 35 22.75 34.3284 22.75 33.5C22.75 32.6716 23.4216 32 24.25 32ZM24.25 13C27.6147 13 30.5 15.8821 30.5 19.2488C30.502 21.3691 29.7314 22.7192 27.8216 24.7772L26.8066 25.8638C25.7842 27.0028 25.3794 27.7252 25.3409 28.5793L25.3379 28.7411L25.3323 28.8689L25.3143 28.9932C25.2018 29.5636 24.7009 29.9957 24.0968 30.0001C23.4065 30.0049 22.8428 29.4493 22.8379 28.7589C22.8251 26.9703 23.5147 25.7467 25.1461 23.9739L26.1734 22.8762C27.5312 21.3837 28.0012 20.503 28 19.25C28 17.2634 26.2346 15.5 24.25 15.5C22.3307 15.5 20.6142 17.1536 20.5055 19.0587L20.4935 19.3778C20.4295 20.0081 19.8972 20.5 19.25 20.5C18.5596 20.5 18 19.9404 18 19.25C18 15.8846 20.8864 13 24.25 13Z</Geometry> + <Geometry x:Key="GeometryWarning">M10.9093922,2.78216375 C11.9491636,2.20625071 13.2471955,2.54089334 13.8850247,3.52240345 L13.9678229,3.66023048 L21.7267791,17.6684928 C21.9115773,18.0021332 22.0085303,18.3772743 22.0085303,18.7586748 C22.0085303,19.9495388 21.0833687,20.9243197 19.9125791,21.003484 L19.7585303,21.0086748 L4.24277801,21.0086748 C3.86146742,21.0086748 3.48641186,20.9117674 3.15282824,20.7270522 C2.11298886,20.1512618 1.7079483,18.8734454 2.20150311,17.8120352 L2.27440063,17.668725 L10.0311968,3.66046274 C10.2357246,3.291099 10.5400526,2.98673515 10.9093922,2.78216375 Z M20.4146132,18.3952808 L12.6556571,4.3870185 C12.4549601,4.02467391 11.9985248,3.89363262 11.6361802,4.09432959 C11.5438453,4.14547244 11.4637001,4.21532637 11.4006367,4.29899869 L11.3434484,4.38709592 L3.58665221,18.3953582 C3.385998,18.7577265 3.51709315,19.2141464 3.87946142,19.4148006 C3.96285732,19.4609794 4.05402922,19.4906942 4.14802472,19.5026655 L4.24277801,19.5086748 L19.7585303,19.5086748 C20.1727439,19.5086748 20.5085303,19.1728883 20.5085303,18.7586748 C20.5085303,18.6633247 20.4903516,18.5691482 20.455275,18.4811011 L20.4146132,18.3952808 L12.6556571,4.3870185 L20.4146132,18.3952808 Z M12.0004478,16.0017852 C12.5519939,16.0017852 12.9991104,16.4489016 12.9991104,17.0004478 C12.9991104,17.5519939 12.5519939,17.9991104 12.0004478,17.9991104 C11.4489016,17.9991104 11.0017852,17.5519939 11.0017852,17.0004478 C11.0017852,16.4489016 11.4489016,16.0017852 12.0004478,16.0017852 Z M11.9962476,8.49954934 C12.3759432,8.49924613 12.689964,8.78114897 12.7399193,9.14718469 L12.7468472,9.24894974 L12.750448,13.7505438 C12.7507788,14.1647572 12.4152611,14.5008121 12.0010476,14.5011439 C11.621352,14.5014471 11.3073312,14.2195442 11.257376,13.8535085 L11.250448,13.7517435 L11.2468472,9.25014944 C11.2465164,8.83593601 11.5820341,8.49988112 11.9962476,8.49954934 Z</Geometry> +</ResourceDictionary> diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml new file mode 100644 index 0000000000..37f99c377d --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml @@ -0,0 +1,64 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mv="using:MarkView.Avalonia" + xmlns:cvt="using:Stride.Core.Presentation.Avalonia.Converters" + xmlns:local="using:Stride.Core.Presentation.Windows" + xmlns:windows="using:Stride.Core.Presentation.Avalonia.Windows" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + MinHeight="120" MinWidth="320" MaxHeight="768" + SizeToContent="WidthAndHeight" CanResize="False" + WindowStartupLocation="CenterOwner" + Padding="8,0" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Center" + x:Class="Stride.Core.Presentation.Avalonia.Windows.MessageBox"> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceInclude Source="avares://Stride.Core.Presentation.Avalonia/Windows/GeometryResources.axaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Window.Resources> + <Window.ContentTemplate> + <DataTemplate> + <DockPanel LastChildFill="True" Margin="8"> + <!-- buttons --> + <ItemsControl DockPanel.Dock="Bottom" + HorizontalAlignment="Right" + ItemsSource="{Binding $parent[windows:MessageBox].ButtonsSource}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <UniformGrid Rows="1" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate DataType="{x:Type local:DialogButtonInfo}"> + <Button Margin="4,0" Padding="16,4" MinWidth="80" + HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + Command="{Binding $parent[windows:MessageBox].ButtonCommand}" + CommandParameter="{Binding Result}" + Content="{Binding Content}" + IsCancel="{Binding IsCancel}" IsDefault="{Binding IsDefault}" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <!-- content --> + <ContentPresenter Content="{Binding $parent[ContentControl].Content}"> + <ContentPresenter.ContentTemplate> + <DataTemplate> + <DockPanel LastChildFill="True"> + <Path DockPanel.Dock="Left" + HorizontalAlignment="Left" VerticalAlignment="Top" + Height="32" Width="32" Margin="4" + Stretch="Uniform" Fill="{DynamicResource SystemAccentColor}" + Data="{Binding $parent[windows:MessageBox].Geometry}" + IsVisible="{Binding $parent[windows:MessageBox].Geometry, Converter={cvt:ObjectToBool}}" /> + <mv:MarkdownViewer Markdown="{ReflectionBinding}" VerticalAlignment="Center" /> + </DockPanel> + </DataTemplate> + </ContentPresenter.ContentTemplate> + </ContentPresenter> + </DockPanel> + </DataTemplate> + </Window.ContentTemplate> +</Window> diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml.cs new file mode 100644 index 0000000000..26d465c401 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageBox.axaml.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Stride.Core.Presentation.Services; +using Stride.Core.Presentation.Windows; + +namespace Stride.Core.Presentation.Avalonia.Windows; + +public partial class MessageBox : MessageDialogBase +{ + public static readonly StyledProperty<Geometry?> GeometryProperty = + AvaloniaProperty.Register<MessageBox, Geometry?>(nameof(Geometry)); + + public MessageBox() + { + InitializeComponent(); + } + + public Geometry? Geometry + { + get { return GetValue(GeometryProperty); } + set { SetValue(GeometryProperty, value); } + } + + public static async Task<int> ShowAsync(string caption, string message, IReadOnlyCollection<DialogButtonInfo> buttons, MessageBoxImage image = MessageBoxImage.None, Window? owner = null) + { + var messageBox = new MessageBox + { + ButtonsSource = buttons, + Content = message, + Title = caption, + }; + SetGeometry(messageBox, image); + SetKeyBindings(messageBox, buttons); + if (owner is not null) + { + await messageBox.ShowDialog(owner); + } + else + { + var tcs = new TaskCompletionSource(); + messageBox.Closed += (_, _) => + { + tcs.SetResult(); + }; + messageBox.Show(); + await tcs.Task; + } + return messageBox.ButtonResult; + } + + protected static void SetKeyBindings(MessageBox messageBox, IEnumerable<DialogButtonInfo> buttons) + { + foreach (var button in buttons.Where(x => !string.IsNullOrEmpty(x.Key))) + { + var binding = new KeyBinding + { + Command = messageBox.ButtonCommand, + CommandParameter = button.Result, + Gesture = KeyGesture.Parse(button.Key) + }; + messageBox.KeyBindings.Add(binding); + } + } + + protected static void SetGeometry(MessageBox messageBox, MessageBoxImage image) + { + var key = image switch + { + MessageBoxImage.None => null, + MessageBoxImage.Error => "GeometryError", + MessageBoxImage.Question => "GeometryQuestion", + MessageBoxImage.Warning => "GeometryWarning", + MessageBoxImage.Information => "GeometryInformation", + _ => throw new ArgumentOutOfRangeException(nameof(image), image, null), + }; + if (key is not null) + { + messageBox.TryGetResource(key, null, out var geometry); + messageBox.Geometry = geometry as Geometry; + } + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageDialogBase.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageDialogBase.cs new file mode 100644 index 0000000000..2e55c4e5e5 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Windows/MessageDialogBase.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Stride.Core.Presentation.Avalonia.Services; +using Stride.Core.Presentation.Commands; +using Stride.Core.Presentation.ViewModels; +using Stride.Core.Presentation.Windows; + +namespace Stride.Core.Presentation.Avalonia.Windows; + +public abstract class MessageDialogBase : Window +{ + private readonly ICommandBase buttonCommand; + private IReadOnlyCollection<DialogButtonInfo> buttonsSource = null!; + + public static readonly DirectProperty<MessageDialogBase, ICommandBase> ButtonCommandProperty = + AvaloniaProperty.RegisterDirect<MessageDialogBase, ICommandBase>(nameof(ButtonCommand), o => o.ButtonCommand); + + public static readonly DirectProperty<MessageDialogBase, IReadOnlyCollection<DialogButtonInfo>> ButtonsSourceProperty = + AvaloniaProperty.RegisterDirect<MessageDialogBase, IReadOnlyCollection<DialogButtonInfo>>(nameof(ButtonsSource), o => o.ButtonsSource); + + protected MessageDialogBase() + { + var serviceProvider = new ViewModelServiceProvider(new[] { DispatcherService.Create() }); + buttonCommand = new AnonymousCommand<int>(serviceProvider, ButtonClick); + } + + public ICommandBase ButtonCommand + { + get => buttonCommand; + } + + public required IReadOnlyCollection<DialogButtonInfo> ButtonsSource + { + get => buttonsSource; + init => SetAndRaise(ButtonsSourceProperty, ref buttonsSource, value); + } + + public int ButtonResult { get; private set; } + + private void ButtonClick(int parameter) + { + ButtonResult = parameter; + Close(); + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Tests/TestObservableList.cs b/sources/presentation/Stride.Core.Presentation.Tests/TestObservableList.cs index 3eeb583ba4..60eaf54a44 100644 --- a/sources/presentation/Stride.Core.Presentation.Tests/TestObservableList.cs +++ b/sources/presentation/Stride.Core.Presentation.Tests/TestObservableList.cs @@ -2,13 +2,16 @@ // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System.Collections.Generic; using System.Collections.Specialized; -using Xunit; +using System.ComponentModel; using Stride.Core.Presentation.Collections; +using Xunit; namespace Stride.Core.Presentation.Tests { public class TestObservableList { + private static readonly string[] collectionPropertyNames = ["Count", "Item[]"]; + [Fact] public void TestEnumerableConstructor() { @@ -39,9 +42,9 @@ public void TestAdd() bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; Assert.Equal(set.Count, list.Count); - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -70,22 +73,31 @@ public void TestAddRange() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => { Assert.Equal(NotifyCollectionChangedAction.Add, e.Action); +#if SUPPORT_RANGE_ACTION Assert.Equal(3, e.NewStartingIndex); +#else + Assert.Contains(e.NewStartingIndex, new[] { 3, 4 }); +#endif Assert.NotNull(e.NewItems); +#if SUPPORT_RANGE_ACTION Assert.Equal(2, e.NewItems.Count); Assert.Equal("ddd", e.NewItems[0]); Assert.Equal("eee", e.NewItems[1]); +#else + Assert.Single(e.NewItems); + Assert.Contains(e.NewItems[0], new[] { "ddd", "eee" }); +#endif collectionChangedInvoked = true; }; - set.AddRange(new[] { "ddd", "eee" }); + set.AddRange(["ddd", "eee"]); Assert.Equal(5, set.Count); Assert.Equal("aaa", set[0]); Assert.Equal("bbb", set[1]); @@ -104,9 +116,9 @@ public void TestClear() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -139,9 +151,9 @@ public void TestRemove() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -185,9 +197,9 @@ public void TestInsert() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -217,9 +229,9 @@ public void TestRemoveAt() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableList<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => diff --git a/sources/presentation/Stride.Core.Presentation.Tests/TestObservableSet.cs b/sources/presentation/Stride.Core.Presentation.Tests/TestObservableSet.cs index 6f371608d5..6e0c75e8ba 100644 --- a/sources/presentation/Stride.Core.Presentation.Tests/TestObservableSet.cs +++ b/sources/presentation/Stride.Core.Presentation.Tests/TestObservableSet.cs @@ -3,13 +3,16 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using Xunit; +using System.ComponentModel; using Stride.Core.Presentation.Collections; +using Xunit; namespace Stride.Core.Presentation.Tests { public class TestObservableSet { + private static readonly string[] collectionPropertyNames = ["Count", "Item[]"]; + [Fact] public void TestEnumerableConstructor() { @@ -49,6 +52,7 @@ public void TestIndexerSetException() var list = new List<string> { "aaa", "bbb", "ccc" }; var set = new ObservableSet<string>(list); Assert.Throws<InvalidOperationException>(() => set[1] = "ccc"); + Assert.Throws<InvalidOperationException>(() => set[2] = "bbb"); Assert.Equal("aaa", set[0]); Assert.Equal("bbb", set[1]); Assert.Equal("ccc", set[2]); @@ -62,9 +66,9 @@ public void TestAdd() bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; Assert.Equal(set.Count, list.Count); - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -93,19 +97,28 @@ public void TestAddRange() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => { Assert.Equal(NotifyCollectionChangedAction.Add, e.Action); +#if SUPPORT_RANGE_ACTION Assert.Equal(3, e.NewStartingIndex); +#else + Assert.Contains(e.NewStartingIndex, new[] { 3, 4 }); +#endif Assert.NotNull(e.NewItems); +#if SUPPORT_RANGE_ACTION Assert.Equal(2, e.NewItems.Count); Assert.Equal("ddd", e.NewItems[0]); Assert.Equal("eee", e.NewItems[1]); +#else + Assert.Single(e.NewItems); + Assert.Contains(e.NewItems[0], new[] { "ddd", "eee" }); +#endif collectionChangedInvoked = true; }; set.AddRange(new[] { "ddd", "eee" }); @@ -127,9 +140,9 @@ public void TestClear() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -162,9 +175,9 @@ public void TestRemove() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -208,9 +221,9 @@ public void TestInsert() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => @@ -240,9 +253,9 @@ public void TestRemoveAt() Assert.Equal(set.Count, list.Count); bool propertyChangedInvoked = false; bool collectionChangedInvoked = false; - set.PropertyChanged += (sender, e) => + ((INotifyPropertyChanged)set).PropertyChanged += (sender, e) => { - Assert.Equal(nameof(ObservableSet<string>.Count), e.PropertyName); + Assert.Contains(e.PropertyName, collectionPropertyNames); propertyChangedInvoked = true; }; set.CollectionChanged += (sender, e) => diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableList.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableList.cs deleted file mode 100644 index 6488a24e31..0000000000 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableList.cs +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Diagnostics.Contracts; -using System.Linq; -using Stride.Core.Annotations; - -namespace Stride.Core.Presentation.Collections -{ - public class ObservableList<T> : IObservableList<T>, IReadOnlyObservableList<T> - { - private readonly List<T> list; - - [CollectionAccess(CollectionAccessType.None)] - public ObservableList() - { - list = new List<T>(); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public ObservableList([NotNull] IEnumerable<T> collection) - { - list = new List<T>(collection); - } - - [CollectionAccess(CollectionAccessType.None)] - public ObservableList(int capacity) - { - list = new List<T>(capacity); - } - - public T this[int index] - { - [CollectionAccess(CollectionAccessType.Read)] - get { return list[index]; } - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - set - { - var oldItem = list[index]; - list[index] = value; - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, oldItem, index); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.None)] - public int Count => list.Count; - - [CollectionAccess(CollectionAccessType.None)] - public bool IsReadOnly => false; - - public event PropertyChangedEventHandler PropertyChanged; - - public event NotifyCollectionChangedEventHandler CollectionChanged; - - [NotNull] - public IList ToIList() - { - return new NonGenericObservableListWrapper<T>(this); - } - - [Pure] - public IEnumerator<T> GetEnumerator() - { - return list.GetEnumerator(); - } - [Pure] - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public void Add(T item) - { - Insert(Count, item); - } - - public void AddRange(IEnumerable<T> items) - { - var itemList = items.ToList(); - if (itemList.Count > 0) - { - list.AddRange(itemList); - - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, itemList, Count - itemList.Count); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public void Clear() - { - var raiseEvent = list.Count > 0; - list.Clear(); - if (raiseEvent) - { - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.Read)] - [Pure] - public bool Contains(T item) - { - return list.Contains(item); - } - - [CollectionAccess(CollectionAccessType.Read)] - public void CopyTo(T[] array, int arrayIndex) - { - list.CopyTo(array, arrayIndex); - } - - [CollectionAccess(CollectionAccessType.Read)] - public int FindIndex([NotNull] Predicate<T> match) - { - return list.FindIndex(match); - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public bool Remove(T item) - { - int index = list.IndexOf(item); - if (index != -1) - { - RemoveAt(index); - } - return index != -1; - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public void RemoveRange(int index, int count) - { - var oldItems = list.Skip(index).Take(count).ToList(); - list.RemoveRange(index, count); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, oldItems, index); - OnCollectionChanged(arg); - } - - [CollectionAccess(CollectionAccessType.Read)] - [Pure] - public int IndexOf(T item) - { - return list.IndexOf(item); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public void Insert(int index, T item) - { - list.Insert(index, item); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index); - OnCollectionChanged(arg); - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public void RemoveAt(int index) - { - var item = list[index]; - list.RemoveAt(index); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index); - OnCollectionChanged(arg); - } - - /// <inheritdoc/> - [CollectionAccess(CollectionAccessType.None)] - public override string ToString() - { - return $"{{ObservableList}} Count = {Count}"; - } - - protected void OnCollectionChanged([NotNull] NotifyCollectionChangedEventArgs arg) - { - CollectionChanged?.Invoke(this, arg); - - switch (arg.Action) - { - case NotifyCollectionChangedAction.Add: - case NotifyCollectionChangedAction.Remove: - case NotifyCollectionChangedAction.Reset: - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); - break; - } - } - - protected void OnPropertyChanged([NotNull] PropertyChangedEventArgs arg) - { - PropertyChanged?.Invoke(this, arg); - } - } -} diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableSet.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableSet.cs deleted file mode 100644 index 949f7b95a8..0000000000 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Collections/ObservableSet.cs +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Diagnostics.Contracts; -using System.Linq; -using Stride.Core.Annotations; - -namespace Stride.Core.Presentation.Collections -{ - public class ObservableSet<T> : IObservableList<T>, IReadOnlyObservableList<T> - { - private readonly HashSet<T> hashSet; - private readonly List<T> list; - - [CollectionAccess(CollectionAccessType.None)] - public ObservableSet() - : this(EqualityComparer<T>.Default) - { - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public ObservableSet([NotNull] IEnumerable<T> collection) - : this(EqualityComparer<T>.Default, collection) - { - } - - [CollectionAccess(CollectionAccessType.None)] - public ObservableSet(IEqualityComparer<T> comparer) - { - hashSet = new HashSet<T>(comparer); - list = new List<T>(); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public ObservableSet(IEqualityComparer<T> comparer, [NotNull] IEnumerable<T> collection) - { - list = new List<T>(); - hashSet = new HashSet<T>(comparer); - foreach (var item in collection) - { - if (hashSet.Add(item)) - list.Add(item); - } - } - - [CollectionAccess(CollectionAccessType.None)] - public ObservableSet(int capacity) - { - hashSet = new HashSet<T>(); - list = new List<T>(capacity); - } - - public T this[int index] - { - [CollectionAccess(CollectionAccessType.Read)] - get { return list[index]; } - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - set - { - var oldItem = list[index]; - hashSet.Remove(oldItem); - if (!hashSet.Add(value)) throw new InvalidOperationException("Unable to set this value at the given index because this value is already contained in this ObservableSet."); - list[index] = value; - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, oldItem, index); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.None)] - public bool IsReadOnly => false; - - public event NotifyCollectionChangedEventHandler CollectionChanged; - - public event PropertyChangedEventHandler PropertyChanged; - - [CollectionAccess(CollectionAccessType.None)] - public int Count => list.Count; - - [Pure] - public IEnumerator<T> GetEnumerator() - { - return list.GetEnumerator(); - } - - [Pure] - IEnumerator IEnumerable.GetEnumerator() - { - return list.GetEnumerator(); - } - - [NotNull, Pure] - public IList ToIList() - { - return new NonGenericObservableListWrapper<T>(this); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public void Add(T item) - { - if (hashSet.Add(item)) - { - list.Add(item); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, list.Count - 1); - OnCollectionChanged(arg); - } - } - - public void AddRange(IEnumerable<T> items) - { - var itemList = items.Where(x => hashSet.Add(x)).ToList(); - if (itemList.Count > 0) - { - list.AddRange(itemList); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, itemList, Count - itemList.Count); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public void Clear() - { - var raiseEvent = list.Count > 0; - hashSet.Clear(); - list.Clear(); - if (raiseEvent) - { - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.Read)] - public bool Contains(T item) - { - return hashSet.Contains(item); - } - - [CollectionAccess(CollectionAccessType.Read)] - public void CopyTo(T[] array, int arrayIndex) - { - list.CopyTo(array, arrayIndex); - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public bool Remove(T item) - { - if (!hashSet.Contains(item)) - return false; - int index = list.IndexOf(item); - if (index != -1) - { - RemoveAt(index); - } - return index != -1; - } - - [CollectionAccess(CollectionAccessType.Read)] - public int IndexOf(T item) - { - return list.IndexOf(item); - } - - [CollectionAccess(CollectionAccessType.UpdatedContent)] - public void Insert(int index, T item) - { - if (hashSet.Add(item)) - { - list.Insert(index, item); - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index); - OnCollectionChanged(arg); - } - } - - [CollectionAccess(CollectionAccessType.ModifyExistingContent)] - public void RemoveAt(int index) - { - var item = list[index]; - list.RemoveAt(index); - hashSet.Remove(item); - - var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index); - OnCollectionChanged(arg); - } - - /// <inheritdoc/> - [CollectionAccess(CollectionAccessType.None)] - public override string ToString() - { - return $"{{ObservableSet}} Count = {Count}"; - } - - protected void OnCollectionChanged([NotNull] NotifyCollectionChangedEventArgs arg) - { - CollectionChanged?.Invoke(this, arg); - - switch (arg.Action) - { - case NotifyCollectionChangedAction.Add: - case NotifyCollectionChangedAction.Remove: - case NotifyCollectionChangedAction.Reset: - OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); - break; - } - } - - protected void OnPropertyChanged([NotNull] PropertyChangedEventArgs arg) - { - PropertyChanged?.Invoke(this, arg); - } - } -} diff --git a/sources/presentation/Stride.Core.Presentation/Collections/ObservableList.cs b/sources/presentation/Stride.Core.Presentation/Collections/ObservableList.cs new file mode 100644 index 0000000000..8f0271dcb6 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation/Collections/ObservableList.cs @@ -0,0 +1,137 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Collections.ObjectModel; +#if SUPPORT_RANGE_ACTION +using System.Collections.Specialized; +using System.ComponentModel; +#endif +using Stride.Core.Annotations; + +namespace Stride.Core.Presentation.Collections +{ + public class ObservableList<T> : ObservableCollection<T>, IObservableList<T>, IReadOnlyObservableList<T> + { + [CollectionAccess(CollectionAccessType.None)] + public ObservableList() + : base() + { + } + + [CollectionAccess(CollectionAccessType.UpdatedContent)] + public ObservableList(IEnumerable<T> collection) + : base(collection) + { + } + + [CollectionAccess(CollectionAccessType.None)] + public ObservableList(int capacity) + : base(new List<T>(capacity)) + { + } + + public void AddRange(IEnumerable<T> items) + { +#if SUPPORT_RANGE_ACTION + // WPF doesn't support range change from within a ObservableCollection-derived class + // cf. System.Windows.Data.ListCollectionView vs MS.Internal.Data.EnumerableCollectionView (which is used as a wrapper for other non-derived ObservableCollection) + // However, we do need to derive from ObservableCollection for Avalonia or some features don't work well (e.g. in tree views) + var itemList = items.ToList(); + if (Items is List<T> list) + { + list.AddRange(itemList); + } + else + { + foreach (var item in itemList) + { + Items.Add(item); + } + } + + if (itemList.Count > 0) + { + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, itemList, Count - itemList.Count); + OnCollectionChanged(arg); + } +#else + foreach (var item in items) + { + Add(item); + } +#endif + } + + [CollectionAccess(CollectionAccessType.Read)] + public int FindIndex(Predicate<T> match) + { + if (Items is List<T> list) + { + return list.FindIndex(match); + } + + for (int i = 0; i < Count; i++) + { + if (match(Items[i])) return i; + } + return -1; + } + + [CollectionAccess(CollectionAccessType.ModifyExistingContent)] + public void RemoveRange(int index, int count) + { +#if SUPPORT_RANGE_ACTION + // WPF doesn't support range change from within a ObservableCollection-derived class + // cf. System.Windows.Data.ListCollectionView vs MS.Internal.Data.EnumerableCollectionView (which is used as a wrapper for other non-derived ObservableCollection) + // However, we do need to derive from ObservableCollection for Avalonia or some features don't work well (e.g. in tree views) + var oldItems = Items.Skip(index).Take(count).ToList(); + if (Items is List<T> list) + { + list.RemoveRange(index, count); + } + else + { + // slow algorithm, optimized from collection's end + for (int i = 1; i <= count; ++i) + { + Items.RemoveAt(index + count - i); + } + } + + if (oldItems.Count > 0) + { + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, oldItems, index); + OnCollectionChanged(arg); + } +#else + for (int i = 1; i <= count; ++i) + { + RemoveAt(index + count - i); + } +#endif + } + + /// <inheritdoc/> + [CollectionAccess(CollectionAccessType.None)] + public override string ToString() + { + return $"{{ObservableList}} Count = {Count}"; + } + +#if SUPPORT_RANGE_ACTION + /// <summary> + /// Helper to raise a PropertyChanged event for the Count property + /// </summary> + private void OnCountPropertyChanged() => OnPropertyChanged(new PropertyChangedEventArgs("Count")); + + /// <summary> + /// Helper to raise a PropertyChanged event for the Indexer property + /// </summary> + private void OnIndexerPropertyChanged() => OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); +#endif + } +} diff --git a/sources/presentation/Stride.Core.Presentation/Collections/ObservableSet.cs b/sources/presentation/Stride.Core.Presentation/Collections/ObservableSet.cs new file mode 100644 index 0000000000..2cd28941a8 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation/Collections/ObservableSet.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Collections.ObjectModel; +#if SUPPORT_RANGE_ACTION +using System.Collections.Specialized; +using System.ComponentModel; +#endif +using Stride.Core.Annotations; + +namespace Stride.Core.Presentation.Collections +{ + public class ObservableSet<T> : ObservableCollection<T>, IObservableList<T>, IReadOnlyObservableList<T> + { + private readonly HashSet<T> hashSet; + + [CollectionAccess(CollectionAccessType.None)] + public ObservableSet() + : this(EqualityComparer<T>.Default) + { + } + + [CollectionAccess(CollectionAccessType.UpdatedContent)] + public ObservableSet(IEnumerable<T> collection) + : this(EqualityComparer<T>.Default, collection) + { + } + + [CollectionAccess(CollectionAccessType.None)] + public ObservableSet(IEqualityComparer<T> comparer) + { + hashSet = new HashSet<T>(comparer); + } + + [CollectionAccess(CollectionAccessType.UpdatedContent)] + public ObservableSet(IEqualityComparer<T> comparer, IEnumerable<T> collection) + { + hashSet = new HashSet<T>(comparer); + AddRange(collection); + } + + [CollectionAccess(CollectionAccessType.None)] + public ObservableSet(int capacity) + : base(new List<T>(capacity)) + { + hashSet = []; + } + + public void AddRange(IEnumerable<T> items) + { +#if SUPPORT_RANGE_ACTION + // WPF doesn't support range change from within a ObservableCollection-derived class + // cf. System.Windows.Data.ListCollectionView vs MS.Internal.Data.EnumerableCollectionView (which is used as a wrapper for other non-derived ObservableCollection) + // However, we do need to derive from ObservableCollection for Avalonia or some features don't work well (e.g. in tree views) + var itemList = items.Where(x => hashSet.Add(x)).ToList(); + if (itemList.Count > 0) + { + foreach (var item in itemList) + { + Items.Add(item); + } + + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + var arg = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, itemList, Count - itemList.Count); + OnCollectionChanged(arg); + } +#else + foreach (var item in items) + { + Add(item); + } +#endif + } + + protected override void ClearItems() + { + hashSet.Clear(); + base.ClearItems(); + } + + protected override void InsertItem(int index, T item) + { + if (hashSet.Add(item)) + { + base.InsertItem(index, item); + } + } + + protected override void SetItem(int index, T item) + { + var oldItem = base[index]; + hashSet.Remove(oldItem); + if (!hashSet.Add(item)) + { + // restore removed item + hashSet.Add(oldItem); + throw new InvalidOperationException("Unable to set this value at the given index because this value is already contained in this ObservableSet."); + } + base.SetItem(index, item); + } + + protected override void RemoveItem(int index) + { + var item = base[index]; + if (!hashSet.Remove(item)) + { + // safety check: shouldn't happen. If it does, we have a failed logic somewhere. + throw new InvalidOperationException("Unable to remove this value at the given index because it wasn't found in the ObservableSet."); + } + base.RemoveItem(index); + } + + /// <inheritdoc/> + [CollectionAccess(CollectionAccessType.None)] + public override string ToString() + { + return $"{{ObservableSet}} Count = {Count}"; + } + +#if SUPPORT_RANGE_ACTION + /// <summary> + /// Helper to raise a PropertyChanged event for the Count property + /// </summary> + private void OnCountPropertyChanged() => OnPropertyChanged(new PropertyChangedEventArgs("Count")); + + /// <summary> + /// Helper to raise a PropertyChanged event for the Indexer property + /// </summary> + private void OnIndexerPropertyChanged() => OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); +#endif + } +} diff --git a/sources/tests/xunit.runner.stride/App.axaml.cs b/sources/tests/xunit.runner.stride/App.axaml.cs index e6ccab2268..ffff691fa9 100644 --- a/sources/tests/xunit.runner.stride/App.axaml.cs +++ b/sources/tests/xunit.runner.stride/App.axaml.cs @@ -23,10 +23,6 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { - // Line below is needed to remove Avalonia data validation. - // Without this line you will get duplicate validations from both Avalonia and CT - BindingPlugins.DataValidators.RemoveAt(0); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow diff --git a/sources/tests/xunit.runner.stride/ViewModels/TestCaseViewModel.cs b/sources/tests/xunit.runner.stride/ViewModels/TestCaseViewModel.cs index 1f57965322..4f34a9640c 100644 --- a/sources/tests/xunit.runner.stride/ViewModels/TestCaseViewModel.cs +++ b/sources/tests/xunit.runner.stride/ViewModels/TestCaseViewModel.cs @@ -17,7 +17,7 @@ public TestCaseViewModel(TestsViewModel tests, ITestCase testCase) TestCase = testCase; } - public void RunTest() + public override void RunTest() { tests.RunTests(this); } diff --git a/sources/tests/xunit.runner.stride/ViewModels/TestGroupViewModel.cs b/sources/tests/xunit.runner.stride/ViewModels/TestGroupViewModel.cs index a04e6b53ac..c0b33cead2 100644 --- a/sources/tests/xunit.runner.stride/ViewModels/TestGroupViewModel.cs +++ b/sources/tests/xunit.runner.stride/ViewModels/TestGroupViewModel.cs @@ -20,7 +20,7 @@ public TestGroupViewModel(TestsViewModel tests, string displayName) public override IEnumerable<TestCaseViewModel> EnumerateTestCases() => Children.SelectMany(x => x.EnumerateTestCases()); - public void RunTest() + public override void RunTest() { tests.RunTests(this); } diff --git a/sources/tests/xunit.runner.stride/ViewModels/TestNodeViewModel.cs b/sources/tests/xunit.runner.stride/ViewModels/TestNodeViewModel.cs index 04c6350d0b..82b2d42d1b 100644 --- a/sources/tests/xunit.runner.stride/ViewModels/TestNodeViewModel.cs +++ b/sources/tests/xunit.runner.stride/ViewModels/TestNodeViewModel.cs @@ -11,26 +11,23 @@ public abstract class TestNodeViewModel : ViewModelBase public abstract TestCaseViewModel? LocateTestCase(ITestCase testCase); - bool running; public bool Running { - get => running; - set => SetProperty(ref running, value); + get; + set => SetValue(ref field, value); } - - bool failed; public bool Failed { - get => failed; - set => SetProperty(ref failed, value); + get; + set => SetValue(ref field, value); } - - bool succeeded; public bool Succeeded { - get => succeeded; - set => SetProperty(ref succeeded, value); + get; + set => SetValue(ref field, value); } public abstract string DisplayName { get; } + + public abstract void RunTest(); } diff --git a/sources/tests/xunit.runner.stride/ViewModels/TestsViewModel.cs b/sources/tests/xunit.runner.stride/ViewModels/TestsViewModel.cs index 06d0d68d34..5951407583 100644 --- a/sources/tests/xunit.runner.stride/ViewModels/TestsViewModel.cs +++ b/sources/tests/xunit.runner.stride/ViewModels/TestsViewModel.cs @@ -90,39 +90,34 @@ await Task.Run(() => }); } - double testCompletion; public double TestCompletion { - get => testCompletion; - set => SetProperty(ref testCompletion, value); + get; + set => SetValue(ref field, value); } - - bool runningTests; public bool RunningTests { - get => runningTests; - set => SetProperty(ref runningTests, value); + get; + set => SetValue(ref field, value); } - bool isInteractiveMode = false; public bool IsInteractiveMode { - get => isInteractiveMode; + get; set { - SetProperty(ref isInteractiveMode, value); - SetInteractiveMode?.Invoke(isInteractiveMode); + SetValue(ref field, value); + SetInteractiveMode?.Invoke(field); } } - bool isForceSaveImage = false; public bool IsForceSaveImage { - get => isForceSaveImage; + get; set { - SetProperty(ref isForceSaveImage, value); - SetForceSaveImage?.Invoke(isForceSaveImage); + SetValue(ref field, value); + SetForceSaveImage?.Invoke(field); } } diff --git a/sources/tests/xunit.runner.stride/ViewModels/ViewModelBase.cs b/sources/tests/xunit.runner.stride/ViewModels/ViewModelBase.cs index 466531a986..f58663923f 100644 --- a/sources/tests/xunit.runner.stride/ViewModels/ViewModelBase.cs +++ b/sources/tests/xunit.runner.stride/ViewModels/ViewModelBase.cs @@ -1,8 +1,100 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. -using CommunityToolkit.Mvvm.ComponentModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace xunit.runner.stride.ViewModels; -public class ViewModelBase : ObservableObject; +public abstract class ViewModelBase : INotifyPropertyChanging, INotifyPropertyChanged +{ + protected readonly Dictionary<string, string[]> DependentProperties = []; + + protected bool SetValue<T>([NotNullIfNotNull(nameof(value))] ref T field, T value, [CallerMemberName] string propertyName = null!) + => SetValue(ref field, value, null, [propertyName]); + + protected bool SetValue<T>([NotNullIfNotNull(nameof(value))] ref T field, T value, params string[] propertyNames) + => SetValue(ref field, value, null, propertyNames); + + protected bool SetValue<T>([NotNullIfNotNull(nameof(value))] ref T field, T value, Action? updateAction, [CallerMemberName] string propertyName = null!) + => SetValue(ref field, value, updateAction, [propertyName]); + + protected virtual bool SetValue<T>([NotNullIfNotNull(nameof(value))] ref T field, T value, Action? updateAction, params string[] propertyNames) + { + if (propertyNames.Length == 0) + throw new ArgumentOutOfRangeException(nameof(propertyNames), "This method must be invoked with at least one property name."); + + if (!EqualityComparer<T>.Default.Equals(field, value)) + { + OnPropertyChanging(propertyNames); + field = value; + updateAction?.Invoke(); + OnPropertyChanged(propertyNames); + return true; + } + + return false; + } + + protected bool SetValue(Action? updateAction, [CallerMemberName] string propertyName = null!) + => SetValue(null, updateAction, [propertyName]); + + protected bool SetValue(Action? updateAction, params string[] propertyNames) + => SetValue(null, updateAction, propertyNames); + + protected bool SetValue(Func<bool>? hasChangedFunction, Action? updateAction, [CallerMemberName] string propertyName = null!) + => SetValue(hasChangedFunction, updateAction, [propertyName]); + + protected bool SetValue(bool hasChanged, Action? updateAction, [CallerMemberName] string propertyName = null!) + => SetValue(() => hasChanged, updateAction, [propertyName]); + + protected bool SetValue(bool hasChanged, Action? updateAction, params string[] propertyNames) + => SetValue(() => hasChanged, updateAction, propertyNames); + + protected virtual bool SetValue(Func<bool>? hasChangedFunction, Action? updateAction, params string[] propertyNames) + { + if (propertyNames.Length == 0) + throw new ArgumentOutOfRangeException(nameof(propertyNames), "This method must be invoked with at least one property name."); + + var hasChanged = hasChangedFunction?.Invoke() ?? true; + if (hasChanged) + { + OnPropertyChanging(propertyNames); + updateAction?.Invoke(); + OnPropertyChanged(propertyNames); + } + return hasChanged; + } + + protected virtual void OnPropertyChanging(params string[] propertyNames) + { + var propertyChanging = PropertyChanging; + foreach (var propertyName in propertyNames) + { + propertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName)); + if (DependentProperties.TryGetValue(propertyName, out var dependentProperties)) + OnPropertyChanging(dependentProperties); + } + } + + protected virtual void OnPropertyChanged(params string[] propertyNames) + { + var propertyChanged = PropertyChanged; + for (var i = 0; i < propertyNames.Length; ++i) + { + var propertyName = propertyNames[propertyNames.Length - 1 - i]; + if (DependentProperties.TryGetValue(propertyName, out var dependentProperties)) + { + var reverseList = new string[dependentProperties.Length]; + for (var j = 0; j < dependentProperties.Length; ++j) + reverseList[j] = dependentProperties[dependentProperties.Length - 1 - j]; + OnPropertyChanged(reverseList); + } + propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + public event PropertyChangingEventHandler? PropertyChanging; + public event PropertyChangedEventHandler? PropertyChanged; +} diff --git a/sources/tests/xunit.runner.stride/Views/MainView.axaml b/sources/tests/xunit.runner.stride/Views/MainView.axaml index 5cfc0631b8..2b2d685f33 100644 --- a/sources/tests/xunit.runner.stride/Views/MainView.axaml +++ b/sources/tests/xunit.runner.stride/Views/MainView.axaml @@ -21,7 +21,7 @@ ItemsSource="{Binding Children}"> <StackPanel Orientation="Horizontal"> <Menu> - <MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"/> + <MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding !$parent[DockPanel].DataContext.RunningTests}"/> </Menu> <TextBlock Text="✘" FontFamily="Segoe UI Symbol" IsVisible="{Binding Failed}" Foreground="Red" /> <TextBlock Text="✔" FontFamily="Segoe UI Symbol" IsVisible="{Binding Succeeded}" Foreground="Green" /> @@ -33,7 +33,7 @@ <DataTemplate DataType="{x:Type vm:TestNodeViewModel}"> <StackPanel Orientation="Horizontal"> <Menu> - <MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"/> + <MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding !$parent[DockPanel].DataContext.RunningTests}"/> </Menu> <TextBlock Text="✘" FontFamily="Segoe UI Symbol" IsVisible="{Binding Failed}" Foreground="Red" /> <TextBlock Text="✔" FontFamily="Segoe UI Symbol" IsVisible="{Binding Succeeded}" Foreground="Green" /> diff --git a/sources/tests/xunit.runner.stride/xunit.runner.stride.csproj b/sources/tests/xunit.runner.stride/xunit.runner.stride.csproj index 3ea3f62e86..7d7ffa90fe 100644 --- a/sources/tests/xunit.runner.stride/xunit.runner.stride.csproj +++ b/sources/tests/xunit.runner.stride/xunit.runner.stride.csproj @@ -19,7 +19,6 @@ <PackageReference Include="Avalonia.Desktop" /> <PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Themes.Fluent" /> - <PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="xunit" /> <PackageReference Include="xunit.runner.utility" /> </ItemGroup> diff --git a/sources/tools/Stride.StorageTool/Stride.StorageTool.csproj b/sources/tools/Stride.StorageTool/Stride.StorageTool.csproj index 791954b3c7..8f2fc30870 100644 --- a/sources/tools/Stride.StorageTool/Stride.StorageTool.csproj +++ b/sources/tools/Stride.StorageTool/Stride.StorageTool.csproj @@ -17,8 +17,8 @@ <PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia.Controls.DataGrid" /> <PackageReference Include="Avalonia.Desktop" /> - <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> - <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" /> + <!--Condition below is needed to remove AvaloniaUI.DiagnosticsSupport package from build output in Release configuration.--> + <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" /> <PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Fonts.Inter" /> </ItemGroup>