diff --git a/src/SourceGenerators/Uno.UI.SourceGenerators/Content/Uno.UI.SourceGenerators.props b/src/SourceGenerators/Uno.UI.SourceGenerators/Content/Uno.UI.SourceGenerators.props index 4d9faac51364..ec3c980b8954 100644 --- a/src/SourceGenerators/Uno.UI.SourceGenerators/Content/Uno.UI.SourceGenerators.props +++ b/src/SourceGenerators/Uno.UI.SourceGenerators/Content/Uno.UI.SourceGenerators.props @@ -68,6 +68,8 @@ + + diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Agent.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Agent.cs index cca3153bba72..4f8ec059ac03 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Agent.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Agent.cs @@ -22,7 +22,8 @@ namespace Uno.UI.RemoteControl.HotReload partial class ClientHotReloadProcessor { private bool _linkerEnabled; - private HotReloadAgent _agent; + private HotReloadAgent? _agent; + private bool _metadataUpdatesEnabled; private static ClientHotReloadProcessor? _instance; private readonly TaskCompletionSource _hotReloadWorkloadSpaceLoaded = new(); @@ -41,6 +42,8 @@ partial void InitializeMetadataUpdater() { _instance = this; + _metadataUpdatesEnabled = BuildMetadataUpdatesEnabled(); + _linkerEnabled = string.Equals(Environment.GetEnvironmentVariable("UNO_BOOTSTRAP_LINKER_ENABLED"), "true", StringComparison.OrdinalIgnoreCase); if (_linkerEnabled) @@ -60,23 +63,25 @@ partial void InitializeMetadataUpdater() }); } - - private bool MetadataUpdatesEnabled + private bool BuildMetadataUpdatesEnabled() { - get - { - var unoRuntimeIdentifier = GetMSBuildProperty("UnoRuntimeIdentifier"); - var targetFramework = GetMSBuildProperty("TargetFramework"); - var buildingInsideVisualStudio = GetMSBuildProperty("BuildingInsideVisualStudio"); - - return - buildingInsideVisualStudio.Equals("true", StringComparison.OrdinalIgnoreCase) - && ( - // As of VS 17.8, when the debugger is not attached, mobile targets can use - // DevServer's hotreload workspace, as visual studio does not enable it on its own. - (!Debugger.IsAttached - && (targetFramework.Contains("-android") || targetFramework.Contains("-ios")))); - } + var unoRuntimeIdentifier = GetMSBuildProperty("UnoRuntimeIdentifier"); + //var targetFramework = GetMSBuildProperty("TargetFramework"); + //var buildingInsideVisualStudio = GetMSBuildProperty("BuildingInsideVisualStudio"); + + return (_forcedHotReloadMode is HotReloadMode.MetadataUpdates or HotReloadMode.Partial) + || unoRuntimeIdentifier.Equals("skia", StringComparison.OrdinalIgnoreCase) + // Disabled until https://github.com/dotnet/runtime/issues/93860 is fixed + // + //|| + //( + // buildingInsideVisualStudio.Equals("true", StringComparison.OrdinalIgnoreCase) + // && ( + // // As of VS 17.8, when the debugger is not attached, mobile targets can use + // // DevServer's hotreload workspace, as visual studio does not enable it on its own. + // (!Debugger.IsAttached + // && (targetFramework.Contains("-android") || targetFramework.Contains("-ios"))))) + ; } private string[] GetMetadataUpdateCapabilities() @@ -153,7 +158,7 @@ private void AssemblyReload(AssemblyDeltaReload assemblyDeltaReload) UpdatedTypes = ReadIntArray(changedTypesReader) }; - _agent.ApplyDeltas(new[] { delta }); + _agent?.ApplyDeltas(new[] { delta }); if (this.Log().IsEnabled(LogLevel.Trace)) { diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Common.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Common.cs new file mode 100644 index 000000000000..08bbafdf7379 --- /dev/null +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Common.cs @@ -0,0 +1,136 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Uno.Extensions; +using Uno.Foundation.Logging; +using Uno.UI.Extensions; +using Uno.UI.Helpers; +using Uno.UI.RemoteControl.HotReload; +using Windows.Storage.Pickers.Provider; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Markup; +using Windows.UI.Xaml.Media; + +#if __IOS__ +using UIKit; +#elif __MACOS__ +using AppKit; +#elif __ANDROID__ +using Uno.UI; +#endif + +[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(Uno.UI.RemoteControl.HotReload.ClientHotReloadProcessor))] + +namespace Uno.UI.RemoteControl.HotReload +{ + partial class ClientHotReloadProcessor + { + private static async IAsyncEnumerable EnumerateHotReloadInstances( + object? instance, + Func> predicate, + string? parentKey) + { + + if (instance is FrameworkElement fe) + { + var instanceTypeName = (instance.GetType().GetOriginalType() ?? instance.GetType()).Name; + var instanceKey = parentKey is not null ? $"{parentKey}_{instanceTypeName}" : instanceTypeName; + var match = await predicate(fe, instanceKey); + if (match is not null) + { + yield return match; + } + + var idx = 0; + foreach (var child in fe.EnumerateChildren()) + { + var inner = EnumerateHotReloadInstances(child, predicate, $"{instanceKey}_[{idx}]"); + idx++; + await foreach (var validElement in inner) + { + yield return validElement; + } + } + } + } + + private static void SwapViews(FrameworkElement oldView, FrameworkElement newView) + { + if (_log.IsEnabled(LogLevel.Trace)) + { + _log.Trace($"Swapping view {newView.GetType()}"); + } + +#if !WINUI + var parentAsContentControl = oldView.GetVisualTreeParent() as ContentControl; + parentAsContentControl = parentAsContentControl ?? (oldView.GetVisualTreeParent() as ContentPresenter)?.FindFirstParent(); +#else + var parentAsContentControl = VisualTreeHelper.GetParent(oldView) as ContentControl; + parentAsContentControl = parentAsContentControl ?? (VisualTreeHelper.GetParent(oldView) as ContentPresenter)?.FindFirstParent(); +#endif + + if ((parentAsContentControl?.Content as FrameworkElement) == oldView) + { + parentAsContentControl.Content = newView; + } + else if (newView is Page newPage && oldView is Page oldPage) + { + // In the case of Page, swapping the actual page is not supported, so we + // need to swap the content of the page instead. This can happen if the Frame + // is using a native presenter which does not use the `Frame.Content` property. + + // Clear any local context, so that the new page can inherit the value coming + // from the parent Frame. It may happen if the old page set it explicitly. + +#if !WINUI + oldPage.ClearValue(Page.DataContextProperty, DependencyPropertyValuePrecedences.Local); +#else + oldPage.ClearValue(Page.DataContextProperty); +#endif + + oldPage.Content = newPage; +#if !WINUI + newPage.Frame = oldPage.Frame; +#endif + } +#if !WINUI + // Currently we don't have SwapViews implementation that works with WinUI + // so skip swapping non-Page views initially for WinUI + else + { + VisualTreeHelper.SwapViews(oldView, newView); + } +#endif + + if (oldView is FrameworkElement oldViewAsFE && newView is FrameworkElement newViewAsFE) + { + PropagateProperties(oldViewAsFE, newViewAsFE); + } + } + + private static void PropagateProperties(FrameworkElement oldView, FrameworkElement newView) + { + if (oldView == null || newView == null) + { + return; + } + + if (newView.DataContext is null + && oldView.DataContext is not null) + { + // If the DataContext is not provided by the page itself, it may + // have been provided by an external actor. Copy the value as is + // in the DataContext of the new element. + + newView.DataContext = oldView.DataContext; + } + } + } +} diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.PartialReload.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.PartialReload.cs index dd6d190328b3..bb8dcb191492 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.PartialReload.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.PartialReload.cs @@ -32,15 +32,20 @@ private void InitializePartialReload() _supportsLightweightHotReload = buildingInsideVisualStudio.Equals("true", StringComparison.OrdinalIgnoreCase) + && (_forcedHotReloadMode is null || _forcedHotReloadMode == HotReloadMode.Partial) && ( // As of VS 17.8, when the debugger is attached, mobile targets don't invoke MetadataUpdateHandlers // and both targets are not providing updated types. We simulate parts of this process // to determine which types have been updated, particularly those with "CreateNewOnMetadataUpdate". - (Debugger.IsAttached - && (targetFramework.Contains("-android") || targetFramework.Contains("-ios"))) + // + // Disabled until https://github.com/dotnet/runtime/issues/93860 is fixed + // + //(Debugger.IsAttached + // && (targetFramework.Contains("-android") || targetFramework.Contains("-ios"))) + //|| // WebAssembly does not support sending updated types, and does not support debugger based hot reload. - || (unoRuntimeIdentifier?.Equals("WebAssembly", StringComparison.OrdinalIgnoreCase) ?? false)); + (unoRuntimeIdentifier?.Equals("WebAssembly", StringComparison.OrdinalIgnoreCase) ?? false)); if (this.Log().IsEnabled(LogLevel.Trace)) { @@ -56,18 +61,6 @@ private void InitializePartialReload() : new(); } - private string GetMSBuildProperty(string property, string defaultValue = "") - { - var output = defaultValue; - - if (_msbuildProperties is not null && !_msbuildProperties.TryGetValue(property, out output)) - { - return defaultValue; - } - - return output; - } - private async Task PartialReload(FileReload fileReload) { if (!_supportsLightweightHotReload) @@ -161,14 +154,17 @@ private async Task ObserveUpdateTypeMapping() this.Log().Trace($"Found {newTypes.Length} updated types ({types})"); } - var actions = _agent.GetMetadataUpdateHandlerActions(); + if (_agent is not null) + { + var actions = _agent.GetMetadataUpdateHandlerActions(); - actions.ClearCache.ForEach(a => a(newTypes)); - actions.UpdateApplication.ForEach(a => a(newTypes)); + actions.ClearCache.ForEach(a => a(newTypes)); + actions.UpdateApplication.ForEach(a => a(newTypes)); - if (this.Log().IsEnabled(LogLevel.Trace)) - { - this.Log().Trace($"ObserveUpdateTypeMapping: Invoked metadata updaters"); + if (this.Log().IsEnabled(LogLevel.Trace)) + { + this.Log().Trace($"ObserveUpdateTypeMapping: Invoked metadata updaters"); + } } return; diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Xaml.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Xaml.cs index 08bbafdf7379..2e5b3b352436 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Xaml.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.Xaml.cs @@ -1,22 +1,27 @@ -#nullable enable - -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; +using Newtonsoft.Json; using Uno.Extensions; using Uno.Foundation.Logging; using Uno.UI.Extensions; using Uno.UI.Helpers; using Uno.UI.RemoteControl.HotReload; +using Uno.UI.RemoteControl.HotReload.Messages; using Windows.Storage.Pickers.Provider; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Markup; using Windows.UI.Xaml.Media; +#if __IOS__ +using _View = UIKit.UIView; +#else +using _View = Windows.UI.Xaml.FrameworkElement; +#endif #if __IOS__ using UIKit; @@ -32,104 +37,139 @@ namespace Uno.UI.RemoteControl.HotReload { partial class ClientHotReloadProcessor { - private static async IAsyncEnumerable EnumerateHotReloadInstances( - object? instance, - Func> predicate, - string? parentKey) - { + private string? _lastUpdatedFilePath; - if (instance is FrameworkElement fe) + private void ReloadFileWithXamlReader(FileReload fileReload) + { + if (!fileReload.IsValid()) { - var instanceTypeName = (instance.GetType().GetOriginalType() ?? instance.GetType()).Name; - var instanceKey = parentKey is not null ? $"{parentKey}_{instanceTypeName}" : instanceTypeName; - var match = await predicate(fe, instanceKey); - if (match is not null) + if (fileReload.FilePath.IsNullOrEmpty() && this.Log().IsEnabled(LogLevel.Debug)) { - yield return match; + this.Log().LogDebug($"FileReload is missing a file path"); } - var idx = 0; - foreach (var child in fe.EnumerateChildren()) + if (fileReload.Content is null && this.Log().IsEnabled(LogLevel.Debug)) { - var inner = EnumerateHotReloadInstances(child, predicate, $"{instanceKey}_[{idx}]"); - idx++; - await foreach (var validElement in inner) - { - yield return validElement; - } + this.Log().LogDebug($"FileReload is missing content"); } + + return; } + + _lastUpdatedFilePath = fileReload.FilePath; + + _ = Windows.ApplicationModel.Core.CoreApplication.MainView.Dispatcher.RunAsync( + Windows.UI.Core.CoreDispatcherPriority.Normal, + async () => + { + await ReloadWithFileAndContent(fileReload.FilePath, fileReload.Content); + + RemoteControlClient.Instance?.NotifyOfEvent(nameof(FileReload), fileReload.FilePath); + }); } - private static void SwapViews(FrameworkElement oldView, FrameworkElement newView) + private async Task ReloadWithFileAndContent(string filePath, string fileContent) { - if (_log.IsEnabled(LogLevel.Trace)) + try { - _log.Trace($"Swapping view {newView.GetType()}"); - } + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug($"XamlReader reloading changed file [{filePath}]"); + } -#if !WINUI - var parentAsContentControl = oldView.GetVisualTreeParent() as ContentControl; - parentAsContentControl = parentAsContentControl ?? (oldView.GetVisualTreeParent() as ContentPresenter)?.FindFirstParent(); -#else - var parentAsContentControl = VisualTreeHelper.GetParent(oldView) as ContentControl; - parentAsContentControl = parentAsContentControl ?? (VisualTreeHelper.GetParent(oldView) as ContentPresenter)?.FindFirstParent(); -#endif + var uri = new Uri("file:///" + filePath.Replace('\\', '/')); - if ((parentAsContentControl?.Content as FrameworkElement) == oldView) - { - parentAsContentControl.Content = newView; - } - else if (newView is Page newPage && oldView is Page oldPage) - { - // In the case of Page, swapping the actual page is not supported, so we - // need to swap the content of the page instead. This can happen if the Frame - // is using a native presenter which does not use the `Frame.Content` property. + Application.RegisterComponent(uri, fileContent); - // Clear any local context, so that the new page can inherit the value coming - // from the parent Frame. It may happen if the old page set it explicitly. + bool IsSameBaseUri(FrameworkElement i) + { + return uri.OriginalString == i.DebugParseContext?.LocalFileUri -#if !WINUI - oldPage.ClearValue(Page.DataContextProperty, DependencyPropertyValuePrecedences.Local); -#else - oldPage.ClearValue(Page.DataContextProperty); -#endif + // Compatibility with older versions of Uno, where BaseUri is set to the + // local file path instead of the component Uri. + || uri.OriginalString == i.BaseUri?.OriginalString; + } - oldPage.Content = newPage; -#if !WINUI - newPage.Frame = oldPage.Frame; -#endif + foreach (var instance in EnumerateInstances(Window.Current.Content, IsSameBaseUri).OfType()) + { + if (XamlReader.LoadUsingXClass(fileContent, uri.ToString()) is FrameworkElement newContent) + { + SwapViews(instance, newContent); + } + } + + if (ResourceResolver.RetrieveDictionaryForFilePath(uri.AbsolutePath) is { } targetDictionary) + { + var replacementDictionary = (ResourceDictionary)XamlReader.Load(fileContent); + targetDictionary.CopyFrom(replacementDictionary); + Application.Current.UpdateResourceBindingsForHotReload(); + } } -#if !WINUI - // Currently we don't have SwapViews implementation that works with WinUI - // so skip swapping non-Page views initially for WinUI - else + catch (Exception e) { - VisualTreeHelper.SwapViews(oldView, newView); - } -#endif + if (e is TargetInvocationException { InnerException: { } innerException }) + { + e = innerException; + } - if (oldView is FrameworkElement oldViewAsFE && newView is FrameworkElement newViewAsFE) - { - PropagateProperties(oldViewAsFE, newViewAsFE); + if (this.Log().IsEnabled(LogLevel.Error)) + { + this.Log().LogError($"Failed reloading changed file [{filePath}]", e); + } + + await _rcClient.SendMessage( + new HotReload.Messages.XamlLoadError( + filePath: filePath, + exceptionType: e.GetType().ToString(), + message: e.Message, + stackTrace: e.StackTrace)); } } - private static void PropagateProperties(FrameworkElement oldView, FrameworkElement newView) + private static IEnumerable EnumerateInstances(object instance, Func predicate) { - if (oldView == null || newView == null) + if (instance is FrameworkElement fe && predicate(fe)) { - return; + yield return fe; } - - if (newView.DataContext is null - && oldView.DataContext is not null) + else if (instance != null) { - // If the DataContext is not provided by the page itself, it may - // have been provided by an external actor. Copy the value as is - // in the DataContext of the new element. + IEnumerable> Dig() + { + switch (instance) + { + case Panel panel: + foreach (var child in panel.Children) + { + yield return EnumerateInstances(child, predicate); + } + break; + + case Border border: + yield return EnumerateInstances(border.Child, predicate); + break; + + case ContentControl control when control.ContentTemplateRoot != null || control.Content != null: + yield return EnumerateInstances(control.ContentTemplateRoot ?? control.Content, predicate); + break; + + case Control control: + yield return EnumerateInstances(control.TemplatedRoot, predicate); + break; + + case ContentPresenter presenter: + yield return EnumerateInstances(presenter.Content, predicate); + break; + } + } - newView.DataContext = oldView.DataContext; + foreach (var inner in Dig()) + { + foreach (var validElement in inner) + { + yield return validElement; + } + } } } } diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs index 467e1752a3e7..8064dd77df2f 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Text; @@ -19,13 +20,13 @@ public partial class ClientHotReloadProcessor : IRemoteControlProcessor private string? _projectPath; private string[]? _xamlPaths; private readonly IRemoteControlClient _rcClient; + private HotReloadMode? _forcedHotReloadMode; private Dictionary? _msbuildProperties; public ClientHotReloadProcessor(IRemoteControlClient rcClient) { _rcClient = rcClient; - InitializeMetadataUpdater(); } partial void InitializeMetadataUpdater(); @@ -44,7 +45,7 @@ public async Task ProcessFrame(Messages.Frame frame) break; case FileReload.Name: - await PartialReload(JsonConvert.DeserializeObject(frame.Content)!); + await ProcessFileReload(JsonConvert.DeserializeObject(frame.Content)!); break; case HotReloadWorkspaceLoadResult.Name: @@ -62,6 +63,18 @@ public async Task ProcessFrame(Messages.Frame frame) return; } + private async Task ProcessFileReload(HotReload.Messages.FileReload fileReload) + { + if ((!_supportsLightweightHotReload && !_metadataUpdatesEnabled) || _forcedHotReloadMode == HotReloadMode.XamlReader) + { + ReloadFileWithXamlReader(fileReload); + } + else + { + await PartialReload(fileReload); + } + } + private async Task ConfigureServer() { var assembly = _rcClient.AppType.Assembly; @@ -88,11 +101,13 @@ private async Task ConfigureServer() _msbuildProperties = Messages.ConfigureServer.BuildMSBuildProperties(config.MSBuildProperties); - ConfigureServer message = new(_projectPath, _xamlPaths, GetMetadataUpdateCapabilities(), MetadataUpdatesEnabled, config.MSBuildProperties); + ConfigureHotReloadMode(); + InitializeMetadataUpdater(); + InitializePartialReload(); - await _rcClient.SendMessage(message); + ConfigureServer message = new(_projectPath, _xamlPaths, GetMetadataUpdateCapabilities(), _metadataUpdatesEnabled, config.MSBuildProperties); - InitializePartialReload(); + await _rcClient.SendMessage(message); } else { @@ -102,4 +117,36 @@ private async Task ConfigureServer() } } } + + private void ConfigureHotReloadMode() + { + var unoHotReloadMode = GetMSBuildProperty("UnoHotReloadMode"); + + if (!string.IsNullOrEmpty(unoHotReloadMode)) + { + if (!Enum.TryParse(unoHotReloadMode, true, out var hotReloadMode)) + { + throw new NotSupportedException($"The hot reload mode {unoHotReloadMode} is not supported."); + } + + _forcedHotReloadMode = hotReloadMode; + + if (this.Log().IsEnabled(LogLevel.Trace)) + { + this.Log().Trace($"Forced Hot Reload Mode:{_forcedHotReloadMode}"); + } + } + } + + private string GetMSBuildProperty(string property, string defaultValue = "") + { + var output = defaultValue; + + if (_msbuildProperties is not null && !_msbuildProperties.TryGetValue(property, out output)) + { + return defaultValue; + } + + return output; + } } diff --git a/src/Uno.UI.RemoteControl/HotReload/HotReloadMode.cs b/src/Uno.UI.RemoteControl/HotReload/HotReloadMode.cs new file mode 100644 index 000000000000..d74417f72450 --- /dev/null +++ b/src/Uno.UI.RemoteControl/HotReload/HotReloadMode.cs @@ -0,0 +1,24 @@ +namespace Uno.UI.RemoteControl.HotReload; + +internal enum HotReloadMode +{ + /// + /// Hot reload is not configured + /// + None = 0, + + /// + /// Hot reload using Metadata updates + /// + MetadataUpdates, + + /// + /// Hot Reload using partial updated types discovery + /// + Partial, + + /// + /// Hot Reload using XAML reader + /// + XamlReader, +} diff --git a/src/Uno.UI.Toolkit/Uno.UI.Toolkit.Windows.csproj b/src/Uno.UI.Toolkit/Uno.UI.Toolkit.Windows.csproj index d2d0dc65608d..024026ba7e99 100644 --- a/src/Uno.UI.Toolkit/Uno.UI.Toolkit.Windows.csproj +++ b/src/Uno.UI.Toolkit/Uno.UI.Toolkit.Windows.csproj @@ -36,7 +36,7 @@ - +