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 ``, ``, ``, ``. `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 (`true`) 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 `` 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 @@
- 11.0.6
+ 12.0.1
+ 12.0.0
+ 12.0.2
-
+
-
-
+
+
+
+
+
+
+
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 @@
+
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 `\sources\launcher`.
-You can then use `msbuild Stride.build /t:Build;PackageInstaller`.
+## From the command line (Windows)
+
+Check out sources in `\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 `` 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 MessageBoxAsync(string message, IReadOnlyCollection buttons, MessageBoxImage image = MessageBoxImage.None)
+ {
+ WasCalled = true;
+ return Task.FromResult(nextMultiButtonResult);
+ }
+
+ public Task MessageBoxAsync(string message, MessageBoxButton buttons = MessageBoxButton.OK, MessageBoxImage image = MessageBoxImage.None)
+ => Task.FromResult(MessageBoxResult.OK);
+
+ public Task CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, MessageBoxButton buttons = MessageBoxButton.OK, MessageBoxImage image = MessageBoxImage.None)
+ => throw new NotImplementedException();
+
+ public Task CheckedMessageBoxAsync(string message, bool? isChecked, string checkboxMessage, IReadOnlyCollection buttons, MessageBoxImage image = MessageBoxImage.None)
+ => throw new NotImplementedException();
+
+ public Task OpenFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList? filters = null)
+ => throw new NotImplementedException();
+
+ public Task> OpenMultipleFilesPickerAsync(UDirectory? initialPath = null, IReadOnlyList? filters = null)
+ => throw new NotImplementedException();
+
+ public Task OpenFolderPickerAsync(UDirectory? initialPath = null)
+ => throw new NotImplementedException();
+
+ public Task SaveFilePickerAsync(UDirectory? initialPath = null, IReadOnlyList? 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(Func 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 InvokeAsync(Func callback, CancellationToken token = default)
+ => Task.FromResult(callback());
+
+ public Task InvokeTask(Func task, CancellationToken token = default) => task();
+
+ public Task InvokeTask(Func> 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 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 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 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 12
+ 0,0,0,20
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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(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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-{
- ///
- /// Interaction logic for App.xaml
- ///
- 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 @@
//
//------------------------------------------------------------------------------
-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 {
///
/// Looks up a localized string similar to Fork on GitHub.
///
- 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 {
}
}
+ ///
+ /// Looks up a localized string similar to Close anyway.
+ ///
+ public static string CloseAnyway {
+ get {
+ return ResourceManager.GetString("CloseAnyway", resourceCulture);
+ }
+ }
+
+ ///
+ /// 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?.
+ ///
+ public static string CloseLauncherInProgressMessage {
+ get {
+ return ResourceManager.GetString("CloseLauncherInProgressMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Are you sure you wish to uninstall version {0}?.
///
@@ -334,6 +356,15 @@ public static string InstallVersion {
}
}
+ ///
+ /// Looks up a localized string similar to Keep launcher open.
+ ///
+ public static string KeepLauncherOpen {
+ get {
+ return ResourceManager.GetString("KeepLauncherOpen", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Stride launcher.
///
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 @@
Discuss about Stride
/!\ Text must be short
-
+
Fork on GitHub
/!\ Text must be short
@@ -448,4 +448,20 @@ Do you want to proceed?
This will restart the launcher.
+
+ Close anyway
+ Button label: closes the launcher even though an operation is in progress.
+
+
+ A Stride version is still being downloaded or installed.
+
+Closing the launcher now will cancel the operation.
+
+Do you want to close anyway?
+ Dialog body shown when the user tries to close the launcher while a download or install is running.
+
+
+ Keep launcher open
+ Button label: cancels the close and keeps the launcher open so the operation can finish.
+
\ 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 @@
//
//------------------------------------------------------------------------------
-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 {
///
/// Looks up a localized string similar to https://github.com/stride3d/stride/.
///
- 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 @@
https://doc.stride3d.net/{0}/studio_getting_started_links.txt
{0}: the major version of Stride (eg. 1.2)
-
+
https://github.com/stride3d/stride/
@@ -161,4 +161,4 @@
https://visualstudio.microsoft.com/downloads
-
+
\ 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 setClipboard;
+
+ private bool isReportVisible;
+
+ public CrashReportViewModel(string applicationName, CrashReportArgs args, Func 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().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 @@
+
+
+
+
+
+
+
+
+
+ Unfortunately, has crashed.
+ Please help us improve Stride by sending information about this crash through Github Issues.
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-{
- ///
- /// Entry point class of the Launcher.
- ///
- 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;
- ///
- /// The entry point function of the launcher.
- ///
- /// The process error code to return.
- [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";
- ///
- /// Returns path of Launcher (we can't use Assembly.GetEntryAssembly().Location in .NET Core, especially with self-publish).
- ///
- ///
- 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);
}
-
- ///
- /// Initializes a instance assuming the entry point assembly is located at the root of the store.
- ///
- /// A new instance of .
- [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;
}
+ }
- ///
- /// Displays a message to the user with OK and Cancel buttons, and returns whether the user cancelled.
- ///
- /// The message to display.
- /// True if the user answered OK, False otherwise.
- 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;
+ }
- ///
- /// Displays an error message to the user with just an OK button.
- ///
- /// The message to display.
- 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.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(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(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 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 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(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;
+
+///
+/// A structure representing the arguments passed to the launcher process.
+///
+internal struct LauncherArguments
{
///
- /// A structure representing the arguments passed to the launcher process.
+ /// An enum representing the type of action this process should perform.
///
- internal struct LauncherArguments
+ public enum ActionType
{
- ///
- /// An enum representing the type of action this process should perform.
- ///
- public enum ActionType
- {
- Run,
- Uninstall,
- }
-
- ///
- /// The list of actions this process should perform.
- ///
- public List Actions;
+ Run,
+ Uninstall,
}
+
+ ///
+ /// The list of actions this process should perform.
+ ///
+ public List 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;
+
+///
+/// An enum representing error codes returned by the launcher process.
+///
+public enum LauncherErrorCode
{
- ///
- /// An enum representing error codes returned by the launcher process.
- ///
- 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
-{
- ///
- /// 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
- ///
- 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();
- }
-
- ///
- /// Setup the Launcher's service interface to handle IPC communications.
- ///
- 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();
- }
-
- ///
- /// Ask users for his/her credentials if no session is authenticated or has expired.
- ///
- /// true if session was validated, false otherwise.
- private async Task 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 FilterStrideMainPackages(this IEnumerable packages) where T : NugetPackage
{
- public static IEnumerable FilterStrideMainPackages(this IEnumerable 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()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+
+ ///
+ /// Returns path of Launcher (we can't use Assembly.GetEntryAssembly().Location in .NET Core, especially with self-publish).
+ ///
+ ///
+ internal static string? GetExecutablePath() => Environment.ProcessPath;
+
+ internal static void RunNewApp(Func 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()
+ .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 @@
-
+
@@ -8,12 +8,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
Any CPU
bin\Release\publish\
FileSystem
- net10.0-windows
true
- win-x64
true
false
true
false
-
\ No newline at end of file
+
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 MostRecentlyUsedSessionsKey = new SettingsKey("Internal/MostRecentlyUsedSessions", InternalSettingsContainer, () => new MRUDictionary());
+ private static readonly SettingsKey MostRecentlyUsedSessionsKey = new("Internal/MostRecentlyUsedSessions", InternalSettingsContainer, () => new MRUDictionary());
- private static readonly SettingsKey StoreCrashEmail = new SettingsKey("Interface/StoreCrashEmail", GameStudioSettingsContainer, "");
+ private static readonly SettingsKey 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 mostRecentlyUsed;
+ private static IReadOnlyCollection? 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 RecentProjectsUpdated;
+ public static event EventHandler? 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 GetMostRecentlyUsed()
+ set
{
- List result;
- lock (LockObject)
+ try
{
- result = new List(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 GetMostRecentlyUsed()
+ {
+ List 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());
}
+ 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 GetInternalConfigPaths()
- {
- yield return EditorPath.InternalConfigPath;
- }
+ private static void UpdateMostRecentlyUsed()
+ {
+ if (updating)
+ return;
- private static IEnumerable 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 GetInternalConfigPaths()
+ {
+ yield return EditorPath.InternalConfigPath;
+ }
+
+ private static IEnumerable 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)SettingsYamlSerializer.Default.Deserialize(eventReader, typeof(List));
+ var initialTimestamp = DateTime.UtcNow.Ticks;
+ return new Dictionary>
{
- const string legacyVersion = "1.3";
- var mru = (List)SettingsYamlSerializer.Default.Deserialize(eventReader, typeof(List));
- var initialTimestamp = DateTime.UtcNow.Ticks;
- return new Dictionary>
- {
- { 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 DeveloperVersions { get; }
+ bool IsTaskCompleted(string taskName);
+ /// Marks the task as completed and persists immediately (same contract as ).
+ 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 CloseLauncherAutomaticallyKey = new("Internal/Launcher/CloseLauncherAutomatically", SettingsContainer, false);
+ private static readonly SettingsKey ActiveVersionKey = new("Internal/Launcher/ActiveVersion", SettingsContainer, "");
+ private static readonly SettingsKey PreferredFrameworkKey = new("Internal/Launcher/PreferredFramework", SettingsContainer, "net10.0");
+ private static readonly SettingsKey CurrentTabKey = new("Internal/Launcher/CurrentTabSessions", SettingsContainer, 0);
+ private static readonly SettingsKey> DeveloperVersionsKey = new("Internal/Launcher/DeveloperVersions", SettingsContainer, () => new List());
+ private static readonly SettingsKey> CompletedTasksKey = new("Internal/Launcher/CompletedTasks", SettingsContainer, () => new List());
+
+ private static readonly string LauncherConfigPath = Path.Combine(EditorPath.UserDataPath, "LauncherSettings.conf");
+
+ private static List 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 CloseLauncherAutomaticallyKey = new SettingsKey("Internal/Launcher/CloseLauncherAutomatically", SettingsContainer, false);
- private static readonly SettingsKey ActiveVersionKey = new SettingsKey("Internal/Launcher/ActiveVersion", SettingsContainer, "");
- private static readonly SettingsKey PreferredFrameworkKey = new SettingsKey("Internal/Launcher/PreferredFramework", SettingsContainer, "net10.0");
- private static readonly SettingsKey CurrentTabKey = new SettingsKey("Internal/Launcher/CurrentTabSessions", SettingsContainer, 0);
- private static readonly SettingsKey> DeveloperVersionsKey = new SettingsKey>("Internal/Launcher/DeveloperVersions", SettingsContainer, () => new List());
+ 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 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 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 CompletedTasks => completedTasks;
- private static IEnumerable 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 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 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();
+ 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();
- 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();
+ try
+ {
+ await UpdateLauncherFiles(dispatcher, services.Get(), 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();
- try
+ selfUpdateWindow = new();
+ selfUpdateWindow.LockWindow();
+ if (Application.Current is App { MainWindow: Window window })
{
- await UpdateLauncherFiles(dispatcher, services.Get(), 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();
- 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();
+ 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();
- 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();
+ 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();
- // 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;
+ }
- ///
- /// 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.
- ///
- /// An function that will display a message box with the given text and OK/Cancel buttons, and returns True if the user pressed OK or False if he pressed Cancel.
- /// The name of the program being uninstalled, used for displaying a dialog message.
- /// The path in which processes to terminate are located.
- /// True if all the processes were terminated, False if the user cancelled the operation.
- /// There is no guarantee that all processes will be killed at the end. An error might occurs when trying to close a process.
- public static bool CloseProcessesInPath(Func showMessage, string uninstallingProgramName, string path)
+ ///
+ /// 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.
+ ///
+ /// An function that will display a message box with the given text and OK/Cancel buttons, and returns True if the user pressed OK or False if he pressed Cancel.
+ /// The name of the program being uninstalled, used for displaying a dialog message.
+ /// The path in which processes to terminate are located.
+ /// True if all the processes were terminated, False if the user cancelled the operation.
+ /// There is no guarantee that all processes will be killed at the end. An error might occurs when trying to close a process.
+ public static async Task CloseProcessesInPathAsync(Func> showMessageAsync, string uninstallingProgramName, string path)
+ {
+ // Check processes
+ var processesWithWindow = new List>();
+ List processes;
+ do
{
- // Check processes
- var processesWithWindow = new List>();
- List 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 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();
- 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().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 CollectPackageProcesses(string installPath)
+ {
+ var result = new List();
+ 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 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 @@
-
+
WinExe
- net10.0-windows
- win-x64
+ Exe
+ net10.0
+ embedded
+ linux-x64;win-x64
false
false
- true
- true
- false
- enable
-
-
- AnyCPU
- bin\Debug\
- false
- TRACE;STRIDE_LAUNCHER
-
-
- AnyCPU
- pdbonly
- true
- bin\Release\
- TRACE;STRIDE_LAUNCHER
-
-
- Resources\Launcher.ico
- Stride.LauncherApp
+ true
+ Assets\Launcher.ico
app.manifest
- Stride.LauncherApp.Program
+ Stride.Launcher.Program
+ $(DefineConstants);STRIDE_LAUNCHER
<_StrideLauncherNuSpecLines>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)Stride.Launcher.nuspec'))
$([System.Text.RegularExpressions.Regex]::Match($(_StrideLauncherNuSpecLines), `(.*)`).Groups[1].Value)
+ enable
+ latest
+ enable
+ true
+
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
+
-
- Packages\PackageSessionHelper.Solution.cs
-
-
- Packages\Package.Constants.cs
-
-
- Editor\EditorPath.cs
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
- PublicResXFileCodeGenerator
- Strings.Designer.cs
+
+
+
Designer
-
-
-
+ Strings.Designer.cs
PublicResXFileCodeGenerator
+
+
+
+ Designer
Urls.Designer.cs
+ PublicResXFileCodeGenerator
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
True
True
Strings.resx
-
+
True
True
Urls.resx
-
+
-
+
+
+
+
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 @@
Stride.Launcher
- 5.0.6
+ 6.0.1
Stride
Stride
MIT
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();
+ 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().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error);
- }
+ await ServiceProvider.Get().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error);
}
+ }
+
+ ///
+ /// Gets the root url of the documentation that should be opened when the user want to open Stride help.
+ ///
+ public string DocumentationRootUrl => GetDocumentationRootUrl(Version);
+
+ ///
+ /// Gets the version related to this documentation page.
+ ///
+ public string Version { get; }
+
+ ///
+ /// Gets or sets the title of this documentation page.
+ ///
+ public string? Title { get; set; }
- ///
- /// Gets the root url of the documentation that should be opened when the user want to open Stride help.
- ///
- public string DocumentationRootUrl => GetDocumentationRootUrl(Version);
-
- ///
- /// Gets the version related to this documentation page.
- ///
- public string Version { get; }
-
- ///
- /// Gets or sets the title of this documentation page.
- ///
- public string Title { get; set; }
-
- ///
- /// Gets or sets the description of this documentation page.
- ///
- public string Description { get; set; }
-
- ///
- /// Gets or sets the url of this documentation page.
- ///
- public string Url { get; set; }
-
- ///
- /// Gets a command that will open the documentation page in the default web browser.
- ///
- public ICommandBase OpenUrlCommand { get; private set; }
-
- public static async Task> FetchGettingStartedPages(IViewModelServiceProvider serviceProvider, string version)
+ ///
+ /// Gets or sets the description of this documentation page.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the url of this documentation page.
+ ///
+ public string? Url { get; set; }
+
+ ///
+ /// Gets a command that will open the documentation page in the default web browser.
+ ///
+ public ICommandBase OpenUrlCommand { get; private set; }
+
+ public static async Task> FetchGettingStartedPages(IViewModelServiceProvider serviceProvider, string version)
+ {
+ var result = new List();
+ string urlData;
+ try
{
- var result = new List();
- 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;
}
-
- ///
- /// Compute the url of a documentation page, given the page name.
- ///
- /// The version related to this documentation page.
- /// The name of the page.
- /// The complete url of the documentation page.
- 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);
- }
+ ///
+ /// Compute the url of a documentation page, given the page name.
+ ///
+ /// The version related to this documentation page.
+ /// The name of the page.
+ /// The complete url of the documentation page.
+ 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
{
- class FrameworkConverter : ValueConverterBase
+ 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
-{
- ///
- /// This class represents the root view model of the launcher.
- ///
- internal class LauncherViewModel : DispatcherViewModel, IPackagesLogger, IDisposable
- {
- private readonly NugetStore store;
- private readonly SortedObservableCollection strideVersions = new SortedObservableCollection();
- private readonly UninstallHelper uninstallHelper;
- private readonly object objectLock = new object();
- private ObservableList 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(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().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 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 RecentProjects { get; } = new ObservableList();
-
- public ObservableList NewsPages { get { return newsPages; } private set { SetValue(ref newsPages, value); } }
-
- public ReleaseNotesViewModel ActiveReleaseNotes { get { return activeReleaseNotes; } set { SetValue(ref activeReleaseNotes, value); } }
-
- public ObservableList ActiveDocumentationPages => ActiveVersion.Yield().Concat(StrideVersions).OfType().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); } }
-
- ///
- /// Gets or Sets the visibility status of this instance.
- ///
- 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().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
- {
- 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 referencedPackages = new(ReferencedPackageEqualityComparer.Instance);
-
- private async Task RemoveUnusedPackages(IEnumerable mainPackages)
- {
- var previousReferencedPackages = referencedPackages;
- referencedPackages = new HashSet(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 currentRecentProjects;
- lock (RecentProjects)
- {
- currentRecentProjects = new List(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().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();
- 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().Where(x => !updatedLocalPackages.Contains(x)))
- strideUninstalledVersion.UpdateLocalPackage(null, Array.Empty());
- });
-
- // 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().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().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().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().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().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().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2019"), MessageBoxButton.YesNo, MessageBoxImage.Question);
- if (result == MessageBoxResult.Yes)
- {
- await VsixPackage2019.ExecuteAction();
- }
- }
- }
- }
- });
- }
-
- ///
- /// Execute action under the exclusive lock .
- ///
- /// Return type of action.
- /// Action to be executed.
- /// Result of executing .
- internal Task RunLockTask(Func 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().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().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().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(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;
+
+///
+/// This class represents the root view model of the launcher.
+///
+public sealed class MainViewModel : DispatcherViewModel, IPackagesLogger, IDisposable
+{
+ private readonly NugetStore store;
+ private readonly SortedObservableCollection strideVersions = [];
+ private readonly UninstallHelper uninstallHelper;
+ private readonly object objectLock = new();
+ private ObservableList 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();
+ 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(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().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();
+ }
+
+ /// Test-only constructor. Skips NuGet, network, and file-system initialisation.
+ 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 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 RecentProjects { get; } = [];
+
+ public ObservableList NewsPages { get { return newsPages; } private set { SetValue(ref newsPages, value); } }
+
+ public ReleaseNotesViewModel ActiveReleaseNotes { get { return activeReleaseNotes; } set { SetValue(ref activeReleaseNotes, value); } }
+
+ public ObservableList ActiveDocumentationPages => ActiveVersion.Yield().Concat(StrideVersions).OfType().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();
+ }
+ }
+ }
+
+ ///
+ /// Gets or Sets the visibility status of this instance.
+ ///
+ 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().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
+ {
+ 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 referencedPackages = new(ReferencedPackageEqualityComparer.Instance);
+
+ private async Task RemoveUnusedPackages(IEnumerable 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 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().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();
+ 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().Where(x => !updatedLocalPackages.Contains(x)))
+ strideUninstalledVersion.UpdateLocalPackage(null, Array.Empty());
+ });
+
+ // 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().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().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().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().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().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().MessageBoxAsync(string.Format(Strings.AskInstallVSIX, "2019"), MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (result == MessageBoxResult.Yes)
+ {
+ await VsixPackage2019.ExecuteAction();
+ }
+ }
+ }
+ }
+ });
+ }
+
+ ///
+ /// Execute action under the exclusive lock .
+ ///
+ /// Return type of action.
+ /// Action to be executed.
+ /// Result of executing .
+ internal Task RunLockTask(Func 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().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().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().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;
+
+ ///
+ /// 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 true.
+ ///
+ /// true if the caller should proceed with closing the window; false if the user chose to keep the launcher open.
+ public async Task 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().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().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error);
- }
+ await ServiceProvider.Get().MessageBoxAsync(Strings.ErrorOpeningBrowser, MessageBoxButton.OK, MessageBoxImage.Error);
}
+ }
- ///
- /// Gets or sets the title of this documentation page.
- ///
- public string Title { get; set; }
+ ///
+ /// Gets or sets the title of this documentation page.
+ ///
+ public string? Title { get; set; }
- ///
- /// Gets or sets the description of this documentation page.
- ///
- public string Description { get; set; }
+ ///
+ /// Gets or sets the description of this documentation page.
+ ///
+ public string? Description { get; set; }
- ///
- /// Gets or sets the url of this documentation page.
- ///
- public string Url { get; set; }
+ ///
+ /// Gets or sets the url of this documentation page.
+ ///
+ public string? Url { get; set; }
- ///
- /// Gets or sets the url of this documentation page.
- ///
- public DateTime Date { get; set; }
+ ///
+ /// Gets or sets the url of this documentation page.
+ ///
+ public DateTime Date { get; set; }
- ///
- /// Gets a command that will open the documentation page in the default web browser.
- ///
- public ICommandBase OpenUrlCommand { get; private set; }
+ ///
+ /// Gets a command that will open the documentation page in the default web browser.
+ ///
+ public ICommandBase OpenUrlCommand { get; private set; }
- public static async Task> FetchNewsPages(IViewModelServiceProvider serviceProvider, int maxCount)
+ public static async Task> FetchNewsPages(IViewModelServiceProvider serviceProvider, int maxCount)
+ {
+ var result = new List();
+ try
{
- var result = new List();
- 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
+///
+/// A view model class that represents a Nuget package, as it exists both locally and on a remote server.
+///
+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;
+
///
- /// 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 class.
///
- internal abstract class PackageVersionViewModel : DispatcherViewModel
+ /// The parent instance.
+ /// The related instance.
+ /// The local package of this version, if a local package exists.
+ 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;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent instance.
- /// The related instance.
- /// The local package of this version, if a local package exists.
- 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();
+ }
- ///
- /// Gets the short name of this version.
- ///
- public abstract string Name { get; }
-
- ///
- /// Gets the full name of this version.
- ///
- public abstract string FullName { get; }
-
- ///
- /// Gets the installation path of this version, or null if it is not installed.
- ///
- public virtual string InstallPath => LocalPackage?.Path;
-
- ///
- /// Gets whether a download is available for this version, being an update or a first install.
- ///
- public virtual bool CanBeDownloaded { get { return canBeDownloaded; } private set { SetValue(ref canBeDownloaded, value); } }
-
- ///
- /// Gets whether this package is installed and can be deleted.
- ///
- public virtual bool CanDelete { get { return canDelete; } private set { SetValue(ref canDelete, value); } }
-
- ///
- /// Gets the progress of the current download, in percents.
- ///
- public ProgressAction CurrentProgressAction { get { return currentProgressAction; } private set { SetValue(ref currentProgressAction, value); } }
-
- ///
- /// Gets the progress of the current download, in percents.
- ///
- public int CurrentProgress { get { return currentProgress; } private set { SetValue(ref currentProgress, value); } }
-
- ///
- /// Gets whether this version is being processed, being installed, upgraded or deleted.
- ///
- public bool IsProcessing { get { return isProcessing; } protected set { SetValue(ref isProcessing, value); } }
-
- ///
- /// Gets a string representing the current status while this version is being installed, upgraded or deleted.
- ///
- public string CurrentProcessStatus { get { return currentProcessStatus; } protected set { SetValue(ref currentProcessStatus, value); } }
-
- ///
- /// Gets the command that will download the latest version of the associated package and deploy it.
- ///
- public ICommandBase DownloadCommand { get; }
-
- ///
- /// Gets the command that will delete the associated package.
- ///
- public CommandBase DeleteCommand { get; }
-
- public LauncherViewModel Launcher { get; }
-
- ///
- /// Gets the related instance.
- ///
- protected NugetStore Store { get; }
-
- ///
- /// Gets the message to display when an error occurs during the install of this package.
- ///
- protected abstract string InstallErrorMessage { get; }
-
- ///
- /// Gets the message to display when an error occurs during the uninstall of this package.
- ///
- protected abstract string UninstallErrorMessage { get; }
-
- ///
- /// Updates all the versions of this type from the store. This method should update the and
- /// for each version of the same type, remove versions that do not exist anymore, and add new versions.
- ///
- /// A task that completes when the versions are updated.
- protected abstract Task UpdateVersionsFromStore();
-
- ///
- /// Updates the status of this version, synchronizing the different properties and command state of the view model with the local and server packages status.
- ///
- protected virtual void UpdateStatus()
- {
- UpdateStatusInternal();
- }
+ ///
+ /// Gets the short name of this version.
+ ///
+ public abstract string Name { get; }
- protected void UpdateProgress(ProgressAction action, int progress)
- {
- CurrentProgressAction = action;
- CurrentProgress = progress;
- UpdateInstallStatus();
- }
+ ///
+ /// Gets the full name of this version.
+ ///
+ public abstract string FullName { get; }
- ///
- /// Updates the property according to the value.
- ///
- protected abstract void UpdateInstallStatus();
+ ///
+ /// Gets the installation path of this version, or null if it is not installed.
+ ///
+ public virtual string? InstallPath => LocalPackage?.Path;
- ///
- /// Executes some actions before starting to download this version.
- ///
- protected virtual void BeforeDownload()
- {
- // Intentionally does nothing.
- }
+ ///
+ /// Gets whether a download is available for this version, being an update or a first install.
+ ///
+ public virtual bool CanBeDownloaded { get { return canBeDownloaded; } private set { SetValue(ref canBeDownloaded, value); } }
- ///
- /// Executes some actions after downloading and installing this version.
- ///
- protected virtual void AfterDownload()
- {
- // Intentionally does nothing.
- }
+ ///
+ /// Gets whether this package is installed and can be deleted.
+ ///
+ public virtual bool CanDelete { get { return canDelete; } private set { SetValue(ref canDelete, value); } }
- ///
- /// Downloads the latest version of this package. If a version is already in the local store, it will be deleted first.
- ///
- /// Indicates whether to display error message boxes when an error occurs.
- /// A task that completes when the latest version has been downloaded.
- ///
- /// This method will invoke, from a worker thread, before doing anything, and
- /// if the download successfully completed without exception. In every case, it will also invoke
- /// before completing.
- ///
- public Task Download(bool displayErrorMessage)
- {
- BeforeDownload();
+ ///
+ /// Gets the progress of the current download, in percents.
+ ///
+ public ProgressAction CurrentProgressAction { get { return currentProgressAction; } private set { SetValue(ref currentProgressAction, value); } }
- return Task.Run(async () =>
- {
- IsProcessing = true;
+ ///
+ /// Gets the progress of the current download, in percents.
+ ///
+ 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().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Error);
- await UpdateVersionsFromStore();
- IsProcessing = false;
- return;
- }
+ ///
+ /// Gets whether this version is being processed, being installed, upgraded or deleted.
+ ///
+ public bool IsProcessing { get { return isProcessing; } protected set { SetValue(ref isProcessing, value); } }
- IsProcessing = false;
- throw;
- }
- }
+ ///
+ /// Gets a string representing the current status while this version is being installed, upgraded or deleted.
+ ///
+ public string? CurrentProcessStatus { get { return currentProcessStatus; } protected set { SetValue(ref currentProcessStatus, value); } }
- // Then download and install the latest version.
- bool downloadCompleted = false;
+ ///
+ /// Gets the command that will download the latest version of the associated package and deploy it.
+ ///
+ public ICommandBase DownloadCommand { get; }
+
+ ///
+ /// Gets the command that will delete the associated package.
+ ///
+ public CommandBase DeleteCommand { get; }
+
+ public MainViewModel Launcher { get; }
+
+ ///
+ /// Gets the related instance.
+ ///
+ protected NugetStore Store { get; }
+
+ ///
+ /// Gets the message to display when an error occurs during the install of this package.
+ ///
+ protected abstract string InstallErrorMessage { get; }
+
+ ///
+ /// Gets the message to display when an error occurs during the uninstall of this package.
+ ///
+ protected abstract string UninstallErrorMessage { get; }
+
+ ///
+ /// Updates all the versions of this type from the store. This method should update the and
+ /// for each version of the same type, remove versions that do not exist anymore, and add new versions.
+ ///
+ /// A task that completes when the versions are updated.
+ protected abstract Task UpdateVersionsFromStore();
+
+ ///
+ /// Updates the status of this version, synchronizing the different properties and command state of the view model with the local and server packages status.
+ ///
+ protected virtual void UpdateStatus()
+ {
+ UpdateStatusInternal();
+ }
+
+ protected void UpdateProgress(ProgressAction action, int progress)
+ {
+ CurrentProgressAction = action;
+ CurrentProgress = progress;
+ UpdateInstallStatus();
+ }
+
+ ///
+ /// Updates the property according to the value.
+ ///
+ protected abstract void UpdateInstallStatus();
+
+ ///
+ /// Executes some actions before starting to download this version.
+ ///
+ protected virtual void BeforeDownload()
+ {
+ // Intentionally does nothing.
+ }
+
+ ///
+ /// Executes some actions after downloading and installing this version.
+ ///
+ protected virtual void AfterDownload()
+ {
+ // Intentionally does nothing.
+ }
+
+ ///
+ /// Downloads the latest version of this package. If a version is already in the local store, it will be deleted first.
+ ///
+ /// Indicates whether to display error message boxes when an error occurs.
+ /// A task that completes when the latest version has been downloaded.
+ ///
+ /// This method will invoke, from a worker thread, before doing anything, and
+ /// if the download successfully completed without exception. In every case, it will also invoke
+ /// before completing.
+ ///
+ 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().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().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().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().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().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(ServiceProvider, OpenWith);
- ExploreCommand = new AnonymousCommand(ServiceProvider, Explore);
- RemoveCommand = new AnonymousCommand(ServiceProvider, Remove);
- CompatibleVersions = new ObservableList();
- 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(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 CompatibleVersions { get; private set; }
+ public ObservableList 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().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().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().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ if (version.IsProcessing)
+ {
+ message = string.Format(Strings.ErrorVersionBeingUpdated, StrideVersion);
+ await ServiceProvider.Get().MessageBoxAsync(message, MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ if (!version.CanDelete)
+ {
+ message = string.Format(Strings.ErrorVersionNotInstalled, StrideVersion);
+ var result = await ServiceProvider.Get().MessageBoxAsync(message, MessageBoxButton.YesNoCancel, MessageBoxImage.Information);
+ if (result == MessageBoxResult.Yes)
{
- message = string.Format(Strings.ErrorVersionNotInstalled, StrideVersion);
- var result = await ServiceProvider.Get().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;
+
+///