diff --git a/src/SolutionInfo.cs b/src/SolutionInfo.cs index c064920d34..e92eefdf52 100644 --- a/src/SolutionInfo.cs +++ b/src/SolutionInfo.cs @@ -18,5 +18,5 @@ using System.Resources; [assembly: AssemblyVersion("8.0.0")] // these are FYI and changed automatically -[assembly: AssemblyFileVersion("8.12.0")] -[assembly: AssemblyInformationalVersion("8.12.0")] +[assembly: AssemblyFileVersion("8.12.1")] +[assembly: AssemblyInformationalVersion("8.12.1")] diff --git a/src/Umbraco.Core/Configuration/GlobalSettings.cs b/src/Umbraco.Core/Configuration/GlobalSettings.cs index b7dce21285..ba5baf9e87 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettings.cs @@ -409,26 +409,34 @@ namespace Umbraco.Core.Configuration { if (_sqlWriteLockTimeOut != default) return _sqlWriteLockTimeOut; - var timeOut = 5000; // 5 seconds - var appSettingSqlWriteLockTimeOut = ConfigurationManager.AppSettings[Constants.AppSettings.SqlWriteLockTimeOut]; - if(int.TryParse(appSettingSqlWriteLockTimeOut, out var configuredTimeOut)) - { - // Only apply this setting if it's not excessively high or low - const int minimumTimeOut = 100; - const int maximumTimeOut = 20000; - if (configuredTimeOut >= minimumTimeOut && configuredTimeOut <= maximumTimeOut) // between 0.1 and 20 seconds - { - timeOut = configuredTimeOut; - } - else - { - Current.Logger.Warn($"The `{Constants.AppSettings.SqlWriteLockTimeOut}` setting in web.config is not between the minimum of {minimumTimeOut} ms and maximum of {maximumTimeOut} ms, defaulting back to {timeOut}"); - } - } + var timeOut = GetSqlWriteLockTimeoutFromConfigFile(Current.Logger); _sqlWriteLockTimeOut = timeOut; return _sqlWriteLockTimeOut; } } + + internal static int GetSqlWriteLockTimeoutFromConfigFile(ILogger logger) + { + var timeOut = 5000; // 5 seconds + var appSettingSqlWriteLockTimeOut = ConfigurationManager.AppSettings[Constants.AppSettings.SqlWriteLockTimeOut]; + if (int.TryParse(appSettingSqlWriteLockTimeOut, out var configuredTimeOut)) + { + // Only apply this setting if it's not excessively high or low + const int minimumTimeOut = 100; + const int maximumTimeOut = 20000; + if (configuredTimeOut >= minimumTimeOut && configuredTimeOut <= maximumTimeOut) // between 0.1 and 20 seconds + { + timeOut = configuredTimeOut; + } + else + { + logger.Warn( + $"The `{Constants.AppSettings.SqlWriteLockTimeOut}` setting in web.config is not between the minimum of {minimumTimeOut} ms and maximum of {maximumTimeOut} ms, defaulting back to {timeOut}"); + } + } + + return timeOut; + } } } diff --git a/src/Umbraco.Core/IO/SystemDirectories.cs b/src/Umbraco.Core/IO/SystemDirectories.cs index 485fd7f965..9d09bf2f0d 100644 --- a/src/Umbraco.Core/IO/SystemDirectories.cs +++ b/src/Umbraco.Core/IO/SystemDirectories.cs @@ -25,6 +25,8 @@ namespace Umbraco.Core.IO public static string AppPlugins => "~/App_Plugins"; + public static string AppPluginIcons => "/Backoffice/Icons"; + public static string MvcViews => "~/Views"; public static string PartialViews => MvcViews + "/Partials/"; diff --git a/src/Umbraco.Core/Runtime/SqlMainDomLock.cs b/src/Umbraco.Core/Runtime/SqlMainDomLock.cs index f58b279a8d..144746936d 100644 --- a/src/Umbraco.Core/Runtime/SqlMainDomLock.cs +++ b/src/Umbraco.Core/Runtime/SqlMainDomLock.cs @@ -1,5 +1,6 @@ using NPoco; using System; +using System.Configuration; using System.Data; using System.Data.SqlClient; using System.Diagnostics; @@ -7,6 +8,7 @@ using System.Linq; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; +using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; @@ -18,6 +20,7 @@ namespace Umbraco.Core.Runtime { internal class SqlMainDomLock : IMainDomLock { + private readonly TimeSpan _lockTimeout; private string _lockId; private const string MainDomKeyPrefix = "Umbraco.Core.Runtime.SqlMainDom"; private const string UpdatedSuffix = "_updated"; @@ -40,6 +43,8 @@ namespace Umbraco.Core.Runtime Constants.System.UmbracoConnectionName, _logger, new Lazy(() => new MapperCollection(Enumerable.Empty()))); + + _lockTimeout = TimeSpan.FromMilliseconds(GlobalSettings.GetSqlWriteLockTimeoutFromConfigFile(logger)); } public async Task AcquireLockAsync(int millisecondsTimeout) @@ -121,7 +126,7 @@ namespace Umbraco.Core.Runtime } - return await WaitForExistingAsync(tempId, millisecondsTimeout); + return await WaitForExistingAsync(tempId, millisecondsTimeout).ConfigureAwait(false); } public Task ListenAsync() @@ -134,13 +139,15 @@ namespace Umbraco.Core.Runtime // Create a long running task (dedicated thread) // to poll to check if we are still the MainDom registered in the DB - return Task.Factory.StartNew( - ListeningLoop, - _cancellationTokenSource.Token, - TaskCreationOptions.LongRunning, - // Must explicitly specify this, see https://blog.stephencleary.com/2013/10/continuewith-is-dangerous-too.html - TaskScheduler.Default); - + using (ExecutionContext.SuppressFlow()) + { + return Task.Factory.StartNew( + ListeningLoop, + _cancellationTokenSource.Token, + TaskCreationOptions.LongRunning, + // Must explicitly specify this, see https://blog.stephencleary.com/2013/10/continuewith-is-dangerous-too.html + TaskScheduler.Default); + } } /// @@ -198,7 +205,7 @@ namespace Umbraco.Core.Runtime db.BeginTransaction(IsolationLevel.ReadCommitted); // get a read lock - _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + _sqlServerSyntax.ReadLock(db, _lockTimeout, Constants.Locks.MainDom); if (!IsMainDomValue(_lockId, db)) { @@ -221,10 +228,10 @@ namespace Umbraco.Core.Runtime } } finally - { - db?.CompleteTransaction(); - db?.Dispose(); - } + { + db?.CompleteTransaction(); + db?.Dispose(); + } } } @@ -240,37 +247,40 @@ namespace Umbraco.Core.Runtime { var updatedTempId = tempId + UpdatedSuffix; - return Task.Run(() => + using (ExecutionContext.SuppressFlow()) { - try + return Task.Run(() => { - using var db = _dbFactory.CreateDatabase(); - - var watch = new Stopwatch(); - watch.Start(); - while (true) + try { - // poll very often, we need to take over as fast as we can - // local testing shows the actual query to be executed from client/server is approx 300ms but would change depending on environment/IO - Thread.Sleep(1000); + using var db = _dbFactory.CreateDatabase(); - var acquired = TryAcquire(db, tempId, updatedTempId); - if (acquired.HasValue) - return acquired.Value; - - if (watch.ElapsedMilliseconds >= millisecondsTimeout) + var watch = new Stopwatch(); + watch.Start(); + while (true) { - return AcquireWhenMaxWaitTimeElapsed(db); + // poll very often, we need to take over as fast as we can + // local testing shows the actual query to be executed from client/server is approx 300ms but would change depending on environment/IO + Thread.Sleep(1000); + + var acquired = TryAcquire(db, tempId, updatedTempId); + if (acquired.HasValue) + return acquired.Value; + + if (watch.ElapsedMilliseconds >= millisecondsTimeout) + { + return AcquireWhenMaxWaitTimeElapsed(db); + } } } - } - catch (Exception ex) - { - _logger.Error(ex, "An error occurred trying to acquire and waiting for existing SqlMainDomLock to shutdown"); - return false; - } + catch (Exception ex) + { + _logger.Error(ex, "An error occurred trying to acquire and waiting for existing SqlMainDomLock to shutdown"); + return false; + } - }, _cancellationTokenSource.Token); + }, _cancellationTokenSource.Token); + } } private bool? TryAcquire(IUmbracoDatabase db, string tempId, string updatedTempId) @@ -284,7 +294,7 @@ namespace Umbraco.Core.Runtime { transaction = db.GetTransaction(IsolationLevel.ReadCommitted); // get a read lock - _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + _sqlServerSyntax.ReadLock(db, _lockTimeout, Constants.Locks.MainDom); // the row var mainDomRows = db.Fetch("SELECT * FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey }); @@ -296,7 +306,7 @@ namespace Umbraco.Core.Runtime // which indicates that we // can acquire it and it has shutdown. - _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + _sqlServerSyntax.WriteLock(db, _lockTimeout, Constants.Locks.MainDom); // so now we update the row with our appdomain id InsertLockRecord(_lockId, db); @@ -355,7 +365,7 @@ namespace Umbraco.Core.Runtime { transaction = db.GetTransaction(IsolationLevel.ReadCommitted); - _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + _sqlServerSyntax.WriteLock(db, _lockTimeout, Constants.Locks.MainDom); // so now we update the row with our appdomain id InsertLockRecord(_lockId, db); @@ -438,7 +448,7 @@ namespace Umbraco.Core.Runtime db.BeginTransaction(IsolationLevel.ReadCommitted); // get a write lock - _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + _sqlServerSyntax.WriteLock(db, _lockTimeout, Constants.Locks.MainDom); // When we are disposed, it means we have released the MainDom lock // and called all MainDom release callbacks, in this case diff --git a/src/Umbraco.Core/Scoping/Scope.cs b/src/Umbraco.Core/Scoping/Scope.cs index 4298bec93b..f6ec36125d 100644 --- a/src/Umbraco.Core/Scoping/Scope.cs +++ b/src/Umbraco.Core/Scoping/Scope.cs @@ -346,6 +346,8 @@ namespace Umbraco.Core.Scoping if (this != _scopeProvider.AmbientScope) { + var failedMessage = $"The {nameof(Scope)} {this.InstanceId} being disposed is not the Ambient {nameof(Scope)} {(_scopeProvider.AmbientScope?.InstanceId.ToString() ?? "NULL")}. This typically indicates that a child {nameof(Scope)} was not disposed, or flowed to a child thread that was not awaited, or concurrent threads are accessing the same {nameof(Scope)} (Ambient context) which is not supported. If using Task.Run (or similar) as a fire and forget tasks or to run threads in parallel you must suppress execution context flow with ExecutionContext.SuppressFlow() and ExecutionContext.RestoreFlow()."; + #if DEBUG_SCOPES var ambient = _scopeProvider.AmbientScope; _logger.Debug("Dispose error (" + (ambient == null ? "no" : "other") + " ambient)"); @@ -357,7 +359,7 @@ namespace Umbraco.Core.Scoping + "- ambient ctor ->\r\n" + ambientInfos.CtorStack + "\r\n" + "- dispose ctor ->\r\n" + disposeInfos.CtorStack + "\r\n"); #else - throw new InvalidOperationException("Not the ambient scope."); + throw new InvalidOperationException(failedMessage); #endif } diff --git a/src/Umbraco.Core/Scoping/ScopeProvider.cs b/src/Umbraco.Core/Scoping/ScopeProvider.cs index 3c0fa94327..dc92623860 100644 --- a/src/Umbraco.Core/Scoping/ScopeProvider.cs +++ b/src/Umbraco.Core/Scoping/ScopeProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Data; using System.Runtime.Remoting.Messaging; using System.Web; @@ -240,6 +241,9 @@ namespace Umbraco.Core.Scoping var value = GetHttpContextObject(ContextItemKey, false); return value ?? GetCallContextObject(ContextItemKey); } + + [Obsolete("This setter is not used and will be removed in future versions")] + [EditorBrowsable(EditorBrowsableState.Never)] set { // clear both diff --git a/src/Umbraco.Core/Services/IIconService.cs b/src/Umbraco.Core/Services/IIconService.cs index 963edb22a5..1e1ae38002 100644 --- a/src/Umbraco.Core/Services/IIconService.cs +++ b/src/Umbraco.Core/Services/IIconService.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.ComponentModel; using Umbraco.Core.Models; namespace Umbraco.Core.Services @@ -6,7 +8,7 @@ namespace Umbraco.Core.Services public interface IIconService { /// - /// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path + /// Gets the svg string for the icon name found at the global icons path /// /// /// @@ -15,7 +17,15 @@ namespace Umbraco.Core.Services /// /// Gets a list of all svg icons found at at the global icons path. /// - /// + /// A list of + [Obsolete("This method should not be used - use GetIcons instead")] + [EditorBrowsable(EditorBrowsableState.Never)] IList GetAllIcons(); + + /// + /// Gets a list of all svg icons found at at the global icons path. + /// + /// + IReadOnlyDictionary GetIcons(); } } diff --git a/src/Umbraco.Examine/UmbracoExamineIndex.cs b/src/Umbraco.Examine/UmbracoExamineIndex.cs index e1dd77b994..30d0079f7e 100644 --- a/src/Umbraco.Examine/UmbracoExamineIndex.cs +++ b/src/Umbraco.Examine/UmbracoExamineIndex.cs @@ -24,8 +24,8 @@ namespace Umbraco.Examine // note // wrapping all operations that end up calling base.SafelyProcessQueueItems in a safe call // context because they will fork a thread/task/whatever which should *not* capture our - // call context (and the database it can contain)! ideally we should be able to override - // SafelyProcessQueueItems but that's not possible in the current version of Examine. + // call context (and the database it can contain)! + // TODO: FIX Examine to not flow the ExecutionContext so callers don't need to worry about this! /// /// Used to store the path of a content object @@ -99,6 +99,9 @@ namespace Umbraco.Examine { if (CanInitialize()) { + // Use SafeCallContext to prevent the current CallContext flow to child + // tasks executed in the base class so we don't leak Scopes. + // TODO: See notes at the top of this class using (new SafeCallContext()) { base.PerformDeleteFromIndex(itemIds, onComplete); @@ -106,6 +109,20 @@ namespace Umbraco.Examine } } + protected override void PerformIndexItems(IEnumerable values, Action onComplete) + { + if (CanInitialize()) + { + // Use SafeCallContext to prevent the current CallContext flow to child + // tasks executed in the base class so we don't leak Scopes. + // TODO: See notes at the top of this class + using (new SafeCallContext()) + { + base.PerformIndexItems(values, onComplete); + } + } + } + /// /// Returns true if the Umbraco application is in a state that we can initialize the examine indexes /// diff --git a/src/Umbraco.Tests/Scoping/ScopeTests.cs b/src/Umbraco.Tests/Scoping/ScopeTests.cs index 6c5e9a74b5..7d8984baad 100644 --- a/src/Umbraco.Tests/Scoping/ScopeTests.cs +++ b/src/Umbraco.Tests/Scoping/ScopeTests.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Runtime.Remoting.Messaging; using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using Umbraco.Core; using Umbraco.Core.Persistence; @@ -24,6 +25,119 @@ namespace Umbraco.Tests.Scoping Assert.IsNull(ScopeProvider.AmbientScope); // gone } + [Test] + public void GivenUncompletedScopeOnChildThread_WhenTheParentCompletes_TheTransactionIsRolledBack() + { + ScopeProvider scopeProvider = ScopeProvider; + + Assert.IsNull(ScopeProvider.AmbientScope); + IScope mainScope = scopeProvider.CreateScope(); + + var t = Task.Run(() => + { + IScope nested = scopeProvider.CreateScope(); + Thread.Sleep(2000); + nested.Dispose(); + }); + + Thread.Sleep(1000); // mimic some long running operation that is shorter than the other thread + mainScope.Complete(); + Assert.Throws(() => mainScope.Dispose()); + + Task.WaitAll(t); + } + + [Test] + public void GivenNonDisposedChildScope_WhenTheParentDisposes_ThenInvalidOperationExceptionThrows() + { + // this all runs in the same execution context so the AmbientScope reference isn't a copy + + ScopeProvider scopeProvider = ScopeProvider; + + Assert.IsNull(ScopeProvider.AmbientScope); + IScope mainScope = scopeProvider.CreateScope(); + + IScope nested = scopeProvider.CreateScope(); // not disposing + + InvalidOperationException ex = Assert.Throws(() => mainScope.Dispose()); + Console.WriteLine(ex); + } + + [Test] + public void GivenChildThread_WhenParentDisposedBeforeChild_ParentScopeThrows() + { + // The ambient context is NOT thread safe, even though it has locks, etc... + // This all just goes to show that concurrent threads with scopes is a no-go. + var childWait = new ManualResetEventSlim(false); + var parentWait = new ManualResetEventSlim(false); + + ScopeProvider scopeProvider = ScopeProvider; + + Assert.IsNull(ScopeProvider.AmbientScope); + IScope mainScope = scopeProvider.CreateScope(); + + var t = Task.Run(() => + { + Console.WriteLine("Child Task start: " + scopeProvider.AmbientScope.InstanceId); + // This will evict the parent from the ScopeProvider.StaticCallContextObjects + // and replace it with the child + IScope nested = scopeProvider.CreateScope(); + childWait.Set(); + Console.WriteLine("Child Task scope created: " + scopeProvider.AmbientScope.InstanceId); + parentWait.Wait(); // wait for the parent thread + Console.WriteLine("Child Task before dispose: " + scopeProvider.AmbientScope.InstanceId); + // This will evict the child from the ScopeProvider.StaticCallContextObjects + // and replace it with the parent + nested.Dispose(); + Console.WriteLine("Child Task after dispose: " + scopeProvider.AmbientScope.InstanceId); + }); + + childWait.Wait(); // wait for the child to start and create the scope + // This is a confusing thing (this is not the case in netcore), this is NULL because the + // parent thread's scope ID was evicted from the ScopeProvider.StaticCallContextObjects + // so now the ambient context is null because the GUID in the CallContext doesn't match + // the GUID in the ScopeProvider.StaticCallContextObjects. + Assert.IsNull(scopeProvider.AmbientScope); + // now dispose the main without waiting for the child thread to join + // This will throw because at this stage a child scope has been created which means + // it is the Ambient (top) scope but here we're trying to dispose the non top scope. + Assert.Throws(() => mainScope.Dispose()); + parentWait.Set(); // tell child thread to proceed + Task.WaitAll(t); // wait for the child to dispose + mainScope.Dispose(); // now it's ok + Console.WriteLine("Parent Task disposed: " + scopeProvider.AmbientScope?.InstanceId); + } + + [Test] + public void GivenChildThread_WhenChildDisposedBeforeParent_OK() + { + ScopeProvider scopeProvider = ScopeProvider; + + Assert.IsNull(ScopeProvider.AmbientScope); + IScope mainScope = scopeProvider.CreateScope(); + + // Task.Run will flow the execution context unless ExecutionContext.SuppressFlow() is explicitly called. + // This is what occurs in normal async behavior since it is expected to await (and join) the main thread, + // but if Task.Run is used as a fire and forget thread without being done correctly then the Scope will + // flow to that thread. + var t = Task.Run(() => + { + Console.WriteLine("Child Task start: " + scopeProvider.AmbientScope.InstanceId); + IScope nested = scopeProvider.CreateScope(); + Console.WriteLine("Child Task before dispose: " + scopeProvider.AmbientScope.InstanceId); + nested.Dispose(); + Console.WriteLine("Child Task after disposed: " + scopeProvider.AmbientScope.InstanceId); + }); + + Console.WriteLine("Parent Task waiting: " + scopeProvider.AmbientScope?.InstanceId); + Task.WaitAll(t); + Console.WriteLine("Parent Task disposing: " + scopeProvider.AmbientScope.InstanceId); + mainScope.Dispose(); + Console.WriteLine("Parent Task disposed: " + scopeProvider.AmbientScope?.InstanceId); + + Assert.Pass(); + } + [Test] public void SimpleCreateScope() { diff --git a/src/Umbraco.Tests/Services/ThreadSafetyServiceTest.cs b/src/Umbraco.Tests/Services/ThreadSafetyServiceTest.cs index 33ee2f737a..5c58b35b6d 100644 --- a/src/Umbraco.Tests/Services/ThreadSafetyServiceTest.cs +++ b/src/Umbraco.Tests/Services/ThreadSafetyServiceTest.cs @@ -104,7 +104,7 @@ namespace Umbraco.Tests.Services var threads = new List(); var exceptions = new List(); - Debug.WriteLine("Starting..."); + Console.WriteLine("Starting..."); var done = TraceLocks(); @@ -114,12 +114,12 @@ namespace Umbraco.Tests.Services { try { - Debug.WriteLine("[{0}] Running...", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Running...", Thread.CurrentThread.ManagedThreadId); var name1 = "test-" + Guid.NewGuid(); var content1 = contentService.Create(name1, -1, "umbTextpage"); - Debug.WriteLine("[{0}] Saving content #1.", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Saving content #1.", Thread.CurrentThread.ManagedThreadId); Save(contentService, content1); Thread.Sleep(100); //quick pause for maximum overlap! @@ -127,7 +127,7 @@ namespace Umbraco.Tests.Services var name2 = "test-" + Guid.NewGuid(); var content2 = contentService.Create(name2, -1, "umbTextpage"); - Debug.WriteLine("[{0}] Saving content #2.", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Saving content #2.", Thread.CurrentThread.ManagedThreadId); Save(contentService, content2); } catch (Exception e) @@ -139,16 +139,16 @@ namespace Umbraco.Tests.Services } // start all threads - Debug.WriteLine("Starting threads"); + Console.WriteLine("Starting threads"); threads.ForEach(x => x.Start()); // wait for all to complete - Debug.WriteLine("Joining threads"); + Console.WriteLine("Joining threads"); threads.ForEach(x => x.Join()); done.Set(); - Debug.WriteLine("Checking exceptions"); + Console.WriteLine("Checking exceptions"); if (exceptions.Count == 0) { //now look up all items, there should be 40! @@ -172,7 +172,7 @@ namespace Umbraco.Tests.Services var threads = new List(); var exceptions = new List(); - Debug.WriteLine("Starting..."); + Console.WriteLine("Starting..."); var done = TraceLocks(); @@ -182,18 +182,18 @@ namespace Umbraco.Tests.Services { try { - Debug.WriteLine("[{0}] Running...", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Running...", Thread.CurrentThread.ManagedThreadId); var name1 = "test-" + Guid.NewGuid(); var media1 = mediaService.CreateMedia(name1, -1, Constants.Conventions.MediaTypes.Folder); - Debug.WriteLine("[{0}] Saving media #1.", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Saving media #1.", Thread.CurrentThread.ManagedThreadId); Save(mediaService, media1); Thread.Sleep(100); //quick pause for maximum overlap! var name2 = "test-" + Guid.NewGuid(); var media2 = mediaService.CreateMedia(name2, -1, Constants.Conventions.MediaTypes.Folder); - Debug.WriteLine("[{0}] Saving media #2.", Thread.CurrentThread.ManagedThreadId); + Console.WriteLine("[{0}] Saving media #2.", Thread.CurrentThread.ManagedThreadId); Save(mediaService, media2); } catch (Exception e) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js index 87d976f6d9..73a9617aee 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js @@ -75,9 +75,8 @@ Icon with additional attribute. It can be treated like any other dom element iconHelper.getIcon(icon) .then(data => { - if (data !== null && data.svgString !== undefined) { + if (data && data.svgString) { // Watch source SVG string - //icon.svgString.$$unwrapTrustedValue(); scope.svgString = data.svgString; } }); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js index f26763bd14..f3f5deb695 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/iconhelper.service.js @@ -3,9 +3,9 @@ * @name umbraco.services.iconHelper * @description A helper service for dealing with icons, mostly dealing with legacy tree icons **/ -function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { +function iconHelper($http, $q, $sce, $timeout) { - var converter = [ + const converter = [ { oldIcon: ".sprNew", newIcon: "add" }, { oldIcon: ".sprDelete", newIcon: "remove" }, { oldIcon: ".sprMove", newIcon: "enter" }, @@ -85,15 +85,61 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { { oldIcon: ".sprTreeDeveloperPython", newIcon: "icon-linux" } ]; - var collectedIcons; + let collectedIcons; - var imageConverter = [ - {oldImage: "contour.png", newIcon: "icon-umb-contour"} - ]; + let imageConverter = [ + {oldImage: "contour.png", newIcon: "icon-umb-contour"} + ]; - var iconCache = []; - var liveRequests = []; - var allIconsRequested = false; + const iconCache = []; + const promiseQueue = []; + let resourceLoadStatus = "none"; + + /** + * This is the same approach as use for loading the localized text json + * We don't want multiple requests for the icon collection, so need to track + * the current request state, and resolve the queued requests once the icons arrive + * Subsequent requests are returned immediately as the icons are cached into + */ + function init() { + const deferred = $q.defer(); + + if (resourceLoadStatus === "loaded") { + deferred.resolve(iconCache); + return deferred.promise; + } + + if (resourceLoadStatus === "loading") { + promiseQueue.push(deferred); + return deferred.promise; + } + + resourceLoadStatus = "loading"; + + $http({ method: "GET", url: Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetIcons' }) + .then(function (response) { + resourceLoadStatus = "loaded"; + + for (const [key, value] of Object.entries(response.data.Data)) { + iconCache.push({name: key, svgString: $sce.trustAsHtml(value)}) + } + + deferred.resolve(iconCache); + + //ensure all other queued promises are resolved + for (let p in promiseQueue) { + promiseQueue[p].resolve(iconCache); + } + }, function (err) { + deferred.reject("Something broke"); + //ensure all other queued promises are resolved + for (let p in promiseQueue) { + promiseQueue[p].reject("Something broke"); + } + }); + + return deferred.promise; + } return { @@ -187,67 +233,12 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { /** Gets a single IconModel */ getIcon: function(iconName) { - return $q((resolve, reject) => { - var icon = this._getIconFromCache(iconName); - - if(icon !== undefined) { - resolve(icon); - } else { - var iconRequestPath = Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetIcon?iconName=' + iconName; - - // If the current icon is being requested, wait a bit so that we don't have to make another http request and can instead get the icon from the cache. - // This is a bit rough and ready and could probably be improved used an event based system - if(liveRequests.indexOf(iconRequestPath) >= 0) { - setTimeout(() => { - resolve(this.getIcon(iconName)); - }, 10); - } else { - liveRequests.push(iconRequestPath); - // TODO - fix bug where Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl is undefinied when help icon - umbRequestHelper.resourcePromise( - $http.get(iconRequestPath) - ,'Failed to retrieve icon: ' + iconName) - .then(icon => { - if(icon) { - var trustedIcon = this.defineIcon(icon.Name, icon.SvgString); - - liveRequests = _.filter(liveRequests, iconRequestPath); - - resolve(trustedIcon); - } - }) - .catch(err => { - console.warn(err); - }); - }; - - } - }); + return init().then(icons => icons.find(i => i.name === iconName)); }, /** Gets all the available icons in the backoffice icon folder and returns them as an array of IconModels */ getAllIcons: function() { - return $q((resolve, reject) => { - if(allIconsRequested === false) { - allIconsRequested = true; - - umbRequestHelper.resourcePromise( - $http.get(Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetAllIcons') - ,'Failed to retrieve icons') - .then(icons => { - icons.forEach(icon => { - this.defineIcon(icon.Name, icon.SvgString); - }); - - resolve(iconCache); - }) - .catch(err => { - console.warn(err); - });; - } else { - resolve(iconCache); - } - }); + return init().then(icons => icons); }, /** LEGACY - Return a list of icons from icon fonts, optionally filter them */ @@ -312,9 +303,8 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) { }, /** Returns the cached icon or undefined */ - _getIconFromCache: function(iconName) { - return _.find(iconCache, {name: iconName}); - } + _getIconFromCache: iconName => iconCache.find(icon => icon.name === iconName) + }; } angular.module('umbraco.services').factory('iconHelper', iconHelper); diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 6da1e7bcdf..eb6649a7c4 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -347,9 +347,9 @@ False True - 8120 + 8121 / - http://localhost:8120 + http://localhost:8121 False False diff --git a/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml b/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml index eefd182aff..5166815a1c 100644 --- a/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml +++ b/src/Umbraco.Web.UI/Umbraco/Views/Default.cshtml @@ -121,12 +121,6 @@ @Html.AngularValueResetPasswordCodeInfoScript(ViewData["PasswordResetCode"]) @Html.AngularValueTinyMceAssets() - app.run(["iconHelper", function (iconHelper) { - @* We inject icons to the icon helper(service), since icons can only be loaded if user is authorized. By injecting these to the service they will not be requested as they will become cached. *@ - iconHelper.defineIcon("icon-check", '@Html.Raw(Model.IconCheckData)'); - iconHelper.defineIcon("icon-delete", '@Html.Raw(Model.IconDeleteData)'); - }]); - //required for the noscript trick document.getElementById("mainwrapper").style.display = "inherit"; diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 92b67cbf1b..90e75479be 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -1,4 +1,8 @@ -using System; +using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Owin; +using Microsoft.Owin.Security; +using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -6,24 +10,19 @@ using System.Threading; using System.Threading.Tasks; using System.Web.Mvc; using System.Web.UI; -using Microsoft.AspNet.Identity; -using Microsoft.AspNet.Identity.Owin; -using Microsoft.Owin.Security; -using Newtonsoft.Json; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Manifest; using Umbraco.Core.Models.Identity; -using Umbraco.Web.Models; -using Umbraco.Web.Mvc; using Umbraco.Core.Services; using Umbraco.Web.Composing; using Umbraco.Web.Features; using Umbraco.Web.JavaScript; +using Umbraco.Web.Models; +using Umbraco.Web.Mvc; using Umbraco.Web.Security; -using Umbraco.Web.Services; using Constants = Umbraco.Core.Constants; using JArray = Newtonsoft.Json.Linq.JArray; @@ -40,11 +39,9 @@ namespace Umbraco.Web.Editors private readonly ManifestParser _manifestParser; private readonly UmbracoFeatures _features; private readonly IRuntimeState _runtimeState; - private readonly IIconService _iconService; private BackOfficeUserManager _userManager; private BackOfficeSignInManager _signInManager; - [Obsolete("Use the constructor that injects IIconService.")] public BackOfficeController( ManifestParser manifestParser, UmbracoFeatures features, @@ -55,37 +52,11 @@ namespace Umbraco.Web.Editors IProfilingLogger profilingLogger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) - : this(manifestParser, - features, - globalSettings, - umbracoContextAccessor, - services, - appCaches, - profilingLogger, - runtimeState, - umbracoHelper, - Current.IconService) - { - - } - - public BackOfficeController( - ManifestParser manifestParser, - UmbracoFeatures features, - IGlobalSettings globalSettings, - IUmbracoContextAccessor umbracoContextAccessor, - ServiceContext services, - AppCaches appCaches, - IProfilingLogger profilingLogger, - IRuntimeState runtimeState, - UmbracoHelper umbracoHelper, - IIconService iconService) : base(globalSettings, umbracoContextAccessor, services, appCaches, profilingLogger, umbracoHelper) { _manifestParser = manifestParser; _features = features; _runtimeState = runtimeState; - _iconService = iconService; } protected BackOfficeSignInManager SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager()); @@ -100,7 +71,7 @@ namespace Umbraco.Web.Editors /// public async Task Default() { - var backofficeModel = new BackOfficeModel(_features, GlobalSettings, _iconService); + var backofficeModel = new BackOfficeModel(_features, GlobalSettings); return await RenderDefaultOrProcessExternalLoginAsync( () => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml", backofficeModel), () => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml", backofficeModel)); @@ -186,7 +157,7 @@ namespace Umbraco.Web.Editors { return await RenderDefaultOrProcessExternalLoginAsync( //The default view to render when there is no external login info or errors - () => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/AuthorizeUpgrade.cshtml", new BackOfficeModel(_features, GlobalSettings, _iconService)), + () => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/AuthorizeUpgrade.cshtml", new BackOfficeModel(_features, GlobalSettings)), //The ActionResult to perform if external login is successful () => Redirect("/")); } diff --git a/src/Umbraco.Web/Editors/BackOfficeModel.cs b/src/Umbraco.Web/Editors/BackOfficeModel.cs index cbdafd2e94..d0d2e324f3 100644 --- a/src/Umbraco.Web/Editors/BackOfficeModel.cs +++ b/src/Umbraco.Web/Editors/BackOfficeModel.cs @@ -1,32 +1,19 @@ using System; using Umbraco.Core.Configuration; -using Umbraco.Core.Services; -using Umbraco.Web.Composing; using Umbraco.Web.Features; namespace Umbraco.Web.Editors { - public class BackOfficeModel { - - [Obsolete("Use the overload that injects IIconService.")] - public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings) : this(features, globalSettings, Current.IconService) - { - - } - public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings, IIconService iconService) + public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings) { Features = features; GlobalSettings = globalSettings; - IconCheckData = iconService.GetIcon("icon-check")?.SvgString; - IconDeleteData = iconService.GetIcon("icon-delete")?.SvgString; } public UmbracoFeatures Features { get; } public IGlobalSettings GlobalSettings { get; } - public string IconCheckData { get; } - public string IconDeleteData { get; } } } diff --git a/src/Umbraco.Web/Editors/BackOfficePreviewModel.cs b/src/Umbraco.Web/Editors/BackOfficePreviewModel.cs index cc7356b687..6ace8e7198 100644 --- a/src/Umbraco.Web/Editors/BackOfficePreviewModel.cs +++ b/src/Umbraco.Web/Editors/BackOfficePreviewModel.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Umbraco.Core.Configuration; using Umbraco.Core.Models; -using Umbraco.Core.Services; -using Umbraco.Web.Composing; using Umbraco.Web.Features; namespace Umbraco.Web.Editors @@ -13,21 +10,11 @@ namespace Umbraco.Web.Editors private readonly UmbracoFeatures _features; public IEnumerable Languages { get; } - [Obsolete("Use the overload that injects IIconService.")] public BackOfficePreviewModel( UmbracoFeatures features, IGlobalSettings globalSettings, IEnumerable languages) - : this(features, globalSettings, languages, Current.IconService) - { - } - - public BackOfficePreviewModel( - UmbracoFeatures features, - IGlobalSettings globalSettings, - IEnumerable languages, - IIconService iconService) - : base(features, globalSettings, iconService) + : base(features, globalSettings) { _features = features; Languages = languages; diff --git a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs index dd4dd67681..6e2eeff3aa 100644 --- a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs @@ -1,4 +1,6 @@ -using System; +using ClientDependency.Core.Config; +using Microsoft.Owin; +using System; using System.Collections; using System.Collections.Generic; using System.Configuration; @@ -7,15 +9,11 @@ using System.Runtime.Serialization; using System.Web; using System.Web.Configuration; using System.Web.Mvc; -using ClientDependency.Core.Config; -using Microsoft.Owin; using Microsoft.Owin.Security; using Umbraco.Core; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; -using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; -using Umbraco.Web.Controllers; using Umbraco.Web.Features; using Umbraco.Web.HealthCheck; using Umbraco.Web.Models.ContentEditing; diff --git a/src/Umbraco.Web/Editors/IconController.cs b/src/Umbraco.Web/Editors/IconController.cs index 87303a4e62..2aac92088d 100644 --- a/src/Umbraco.Web/Editors/IconController.cs +++ b/src/Umbraco.Web/Editors/IconController.cs @@ -1,13 +1,20 @@ -using System.Collections.Generic; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; using Umbraco.Core.Models; using Umbraco.Core.Services; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; +using Umbraco.Web.WebApi.Filters; namespace Umbraco.Web.Editors { [PluginController("UmbracoApi")] - public class IconController : UmbracoAuthorizedApiController + [IsBackOffice] + [UmbracoWebApiRequireHttps] + [UnhandedExceptionLoggerConfiguration] + [EnableDetailedErrors] + public class IconController : UmbracoApiController { private readonly IIconService _iconService; @@ -30,9 +37,22 @@ namespace Umbraco.Web.Editors /// Gets a list of all svg icons found at at the global icons path. /// /// + [Obsolete("This method should not be used - use GetIcons instead")] public IList GetAllIcons() { return _iconService.GetAllIcons(); } + + /// + /// Gets a list of all svg icons found at at the global icons path. + /// + /// + public JsonNetResult GetIcons() + { + return new JsonNetResult(JsonNetResult.DefaultJsonSerializerSettings) { + Data = _iconService.GetIcons(), + Formatting = Formatting.None + }; + } } } diff --git a/src/Umbraco.Web/Editors/PreviewController.cs b/src/Umbraco.Web/Editors/PreviewController.cs index f00805d2dc..e2770b14ba 100644 --- a/src/Umbraco.Web/Editors/PreviewController.cs +++ b/src/Umbraco.Web/Editors/PreviewController.cs @@ -9,10 +9,8 @@ using Umbraco.Core.Services; using Umbraco.Web.Composing; using Umbraco.Web.Features; using Umbraco.Web.JavaScript; -using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; using Umbraco.Web.PublishedCache; -using Umbraco.Web.Services; using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.Editors @@ -25,39 +23,19 @@ namespace Umbraco.Web.Editors private readonly IPublishedSnapshotService _publishedSnapshotService; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly ILocalizationService _localizationService; - private readonly IIconService _iconService; - [Obsolete("Use the constructor that injects IIconService.")] public PreviewController( UmbracoFeatures features, IGlobalSettings globalSettings, IPublishedSnapshotService publishedSnapshotService, IUmbracoContextAccessor umbracoContextAccessor, ILocalizationService localizationService) - :this(features, - globalSettings, - publishedSnapshotService, - umbracoContextAccessor, - localizationService, - Current.IconService) - { - - } - - public PreviewController( - UmbracoFeatures features, - IGlobalSettings globalSettings, - IPublishedSnapshotService publishedSnapshotService, - IUmbracoContextAccessor umbracoContextAccessor, - ILocalizationService localizationService, - IIconService iconService) { _features = features; _globalSettings = globalSettings; _publishedSnapshotService = publishedSnapshotService; _umbracoContextAccessor = umbracoContextAccessor; _localizationService = localizationService; - _iconService = iconService; } [UmbracoAuthorize(redirectToUmbracoLogin: true)] @@ -74,7 +52,7 @@ namespace Umbraco.Web.Editors availableLanguages = availableLanguages.Where(language => content.Cultures.ContainsKey(language.IsoCode)); } - var model = new BackOfficePreviewModel(_features, _globalSettings, availableLanguages, _iconService); + var model = new BackOfficePreviewModel(_features, _globalSettings, availableLanguages); if (model.PreviewExtendedHeaderView.IsNullOrWhiteSpace() == false) { diff --git a/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs b/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs index 81bb45e270..10ce4d6943 100644 --- a/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs +++ b/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs @@ -319,7 +319,10 @@ namespace Umbraco.Web.Scheduling // create a new token source since this is a new process _shutdownTokenSource = new CancellationTokenSource(); _shutdownToken = _shutdownTokenSource.Token; - _runningTask = Task.Run(async () => await Pump().ConfigureAwait(false), _shutdownToken); + using (ExecutionContext.SuppressFlow()) + { + _runningTask = Task.Run(async () => await Pump().ConfigureAwait(false), _shutdownToken); + } _logger.Debug("{LogPrefix} Starting", _logPrefix); } @@ -544,10 +547,14 @@ namespace Umbraco.Web.Scheduling try { if (bgTask.IsAsync) + { // configure await = false since we don't care about the context, we're on a background thread. await bgTask.RunAsync(token).ConfigureAwait(false); + } else + { bgTask.Run(); + } } finally // ensure we disposed - unless latched again ie wants to re-run { @@ -710,14 +717,20 @@ namespace Umbraco.Web.Scheduling // with a single aspnet thread during shutdown and we don't want to delay other calls to IRegisteredObject.Stop. if (!immediate) { - return Task.Run(StopInitial, CancellationToken.None); + using (ExecutionContext.SuppressFlow()) + { + return Task.Run(StopInitial, CancellationToken.None); + } } else { lock (_locker) { if (_terminated) return Task.CompletedTask; - return Task.Run(StopImmediate, CancellationToken.None); + using (ExecutionContext.SuppressFlow()) + { + return Task.Run(StopImmediate, CancellationToken.None); + } } } } diff --git a/src/Umbraco.Web/Services/IconService.cs b/src/Umbraco.Web/Services/IconService.cs index 175650fd12..15e673e6ba 100644 --- a/src/Umbraco.Web/Services/IconService.cs +++ b/src/Umbraco.Web/Services/IconService.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; using Ganss.XSS; using Umbraco.Core; +using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Models; @@ -14,31 +16,43 @@ namespace Umbraco.Web.Services { private readonly IGlobalSettings _globalSettings; private readonly IHtmlSanitizer _htmlSanitizer; + private readonly IAppPolicyCache _cache; - public IconService(IGlobalSettings globalSettings, IHtmlSanitizer htmlSanitizer) + public IconService(IGlobalSettings globalSettings, IHtmlSanitizer htmlSanitizer, AppCaches appCaches) { _globalSettings = globalSettings; _htmlSanitizer = htmlSanitizer; + _cache = appCaches.RuntimeCache; } - /// - public IList GetAllIcons() - { - var directory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/")); - var iconNames = directory.GetFiles("*.svg"); + public IReadOnlyDictionary GetIcons() => GetIconDictionary(); - return iconNames.OrderBy(f => f.Name) - .Select(iconInfo => GetIcon(iconInfo)).WhereNotNull().ToList(); - - } + /// + public IList GetAllIcons() => + GetIconDictionary() + .Select(x => new IconModel { Name = x.Key, SvgString = x.Value }) + .ToList(); /// public IconModel GetIcon(string iconName) { - return string.IsNullOrWhiteSpace(iconName) - ? null - : CreateIconModel(iconName.StripFileExtension(), IOHelper.MapPath($"{_globalSettings.IconsPath}/{iconName}.svg")); + if (iconName.IsNullOrWhiteSpace()) + { + return null; + } + + var allIconModels = GetIconDictionary(); + if (allIconModels.ContainsKey(iconName)) + { + return new IconModel + { + Name = iconName, + SvgString = allIconModels[iconName] + }; + } + + return null; } /// @@ -79,5 +93,52 @@ namespace Umbraco.Web.Services return null; } } + + private IEnumerable GetAllIconsFiles() + { + var icons = new HashSet(new CaseInsensitiveFileInfoComparer()); + + // add icons from plugins + var appPluginsDirectoryPath = IOHelper.MapPath(SystemDirectories.AppPlugins); + if (Directory.Exists(appPluginsDirectoryPath)) + { + var appPlugins = new DirectoryInfo(appPluginsDirectoryPath); + + // iterate sub directories of app plugins + foreach (var dir in appPlugins.EnumerateDirectories()) + { + var iconPath = IOHelper.MapPath($"{SystemDirectories.AppPlugins}/{dir.Name}{SystemDirectories.AppPluginIcons}"); + if (Directory.Exists(iconPath)) + { + var dirIcons = new DirectoryInfo(iconPath).EnumerateFiles("*.svg", SearchOption.TopDirectoryOnly); + icons.UnionWith(dirIcons); + } + } + } + + // add icons from IconsPath if not already added from plugins + var coreIconsDirectory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/")); + var coreIcons = coreIconsDirectory.GetFiles("*.svg"); + + icons.UnionWith(coreIcons); + + return icons; + } + + private class CaseInsensitiveFileInfoComparer : IEqualityComparer + { + public bool Equals(FileInfo one, FileInfo two) => StringComparer.InvariantCultureIgnoreCase.Equals(one.Name, two.Name); + + public int GetHashCode(FileInfo item) => StringComparer.InvariantCultureIgnoreCase.GetHashCode(item.Name); + } + + private IReadOnlyDictionary GetIconDictionary() => _cache.GetCacheItem( + $"{typeof(IconService).FullName}.{nameof(GetIconDictionary)}", + () => GetAllIconsFiles() + .Select(GetIcon) + .Where(i => i != null) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().SvgString, StringComparer.OrdinalIgnoreCase) + ); } }