From ee240ca378d19ac002bc2ee38d3332883280d814 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 30 Apr 2026 14:22:53 -0600 Subject: [PATCH 1/4] feat: add ServeDebugAppRequestHandler for serving debug app assets and improve error handling in LoginRequestHandler Co-authored-by: Copilot --- src/PepperDash.Core/Web/WebApiServer.cs | 67 +++-- .../Web/EssentialsWebApi.cs | 10 +- .../Web/EssentialsWebApiHelpers.cs | 6 +- .../RequestHandlers/LoginRequestHandler.cs | 9 +- .../ServeDebugAppRequestHandler.cs | 230 ++++++++++++++++++ src/PepperDash.Essentials/ControlSystem.cs | 2 - 6 files changed, 288 insertions(+), 36 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index cd113c15..d4b7890f 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -5,6 +5,7 @@ using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PepperDash.Core.Logging; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Core.Web @@ -25,24 +26,24 @@ namespace PepperDash.Core.Web private readonly CCriticalSection _serverLock = new CCriticalSection(); private HttpCwsServer _server; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; private set; } - /// - /// Gets or sets the BasePath - /// + /// + /// Gets or sets the BasePath + /// public string BasePath { get; private set; } - /// - /// Gets or sets the IsRegistered - /// + /// + /// Gets or sets the IsRegistered + /// public bool IsRegistered { get; private set; } /// @@ -91,7 +92,7 @@ namespace PepperDash.Core.Web /// /// /// - public WebApiServer(string key, string name, string basePath) + public WebApiServer(string key, string name, string basePath) { Key = key; Name = string.IsNullOrEmpty(name) ? DefaultName : name; @@ -139,9 +140,9 @@ namespace PepperDash.Core.Web Start(); } - /// - /// Initialize method - /// + /// + /// Initialize method + /// public void Initialize(string key, string basePath) { Key = key; @@ -167,9 +168,9 @@ namespace PepperDash.Core.Web /// Removes a route from CWS /// /// - /// - /// RemoveRoute method - /// + /// + /// RemoveRoute method + /// public void RemoveRoute(HttpCwsRoute route) { if (route == null) @@ -181,9 +182,25 @@ namespace PepperDash.Core.Web _server.Routes.Remove(route); } - /// - /// GetRouteCollection method - /// + /// + /// Sets the fallback request handler that is invoked when no registered route + /// matches an incoming request. Must be called before . + /// + /// The handler to use as the server-level fallback. + public void SetFallbackHandler(IHttpCwsHandler handler) + { + if (handler == null) + { + this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring"); + return; + } + + _server.HttpRequestHandler = handler; + } + + /// + /// GetRouteCollection method + /// public HttpCwsRouteCollection GetRouteCollection() { return _server.Routes; @@ -227,9 +244,9 @@ namespace PepperDash.Core.Web } } - /// - /// Stop method - /// + /// + /// Stop method + /// public void Stop() { try diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index dfed5fdd..071e18f1 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -17,6 +17,9 @@ namespace PepperDash.Essentials.Core.Web { private readonly WebApiServer _server; + private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -70,6 +73,9 @@ namespace PepperDash.Essentials.Core.Web _server = new WebApiServer(Key, Name, BasePath); + _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug"); + _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler()); + SetupRoutes(); } @@ -242,6 +248,7 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); _server.Start(); + _debugServer.Start(); GetPaths(); @@ -252,7 +259,8 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); _server.Start(); - + _debugServer.Start(); + GetPaths(); } diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 397278f5..c966ea59 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,7 +36,7 @@ namespace PepperDash.Essentials.Core.Web }; } - + /// /// MapToDeviceListObject method @@ -119,9 +119,5 @@ namespace PepperDash.Essentials.Core.Web }; } - internal static bool IsAuthenticated(HttpCwsContext context) - { - throw new NotImplementedException(); - } } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 67aded59..8d724f11 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -71,14 +71,17 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { context.Response.StatusCode = 401; context.Response.StatusDescription = "Bad Request"; + context.Response.StatusDescription = "Unauthorized"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); context.Response.End(); - return; } if (!token.Valid) - { - context.Response.StatusCode = 401; + { + context.Response.StatusCode = 401; context.Response.StatusDescription = "Unauthorized"; context.Response.End(); diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs new file mode 100644 index 00000000..3e48859e --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.WebScripting; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; +using Serilog.Events; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Serves the React debug app from the processor's /HTML/debug folder. + /// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here. + /// Text assets are sent as UTF-8 strings; binary assets are written to the response + /// OutputStream. Any sub-path that does not match a real file falls back to + /// index.html so that client-side (React Router) routing continues to work. + /// + public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler + { + private static readonly Dictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".html", "text/html; charset=utf-8" }, + { ".htm", "text/html; charset=utf-8" }, + { ".js", "application/javascript" }, + { ".mjs", "application/javascript" }, + { ".jsx", "application/javascript" }, + { ".css", "text/css" }, + { ".json", "application/json" }, + { ".map", "application/json" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".woff", "font/woff" }, + { ".woff2","font/woff2" }, + { ".ttf", "font/ttf" }, + { ".eot", "application/vnd.ms-fontobject" }, + }; + + private static readonly HashSet TextExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml" + }; + + /// + /// Constructor. CORS is enabled so browser dev-tools requests succeed. + /// + public ServeDebugAppRequestHandler() : base(true) { } + + /// + /// Handles GET requests for the debug app and its static assets. + /// + protected override void HandleGet(HttpCwsContext context) + { + // When acting as the server-level fallback handler, only handle + // requests that are actually for the /debug path; defer everything + // else to the base class (which returns 501 Not Implemented). + var rawUrl = context.Request.RawUrl ?? string.Empty; + if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0) + { + base.HandleGet(context); + return; + } + + try + { + var htmlDebugPath = GetHtmlDebugPath(); + if (htmlDebugPath == null) + { + SendResponse(context, 500, "Internal Server Error"); + return; + } + + var requestedPath = GetRequestedFilePath(context); + + // Paths with no file extension are SPA client-side routes — serve index.html + string candidate; + if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath)) + { + candidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + } + else + { + var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar); + candidate = System.IO.Path.Combine(htmlDebugPath, relativePart); + } + + // Resolve to an absolute path and guard against path-traversal attacks + var resolvedCandidate = System.IO.Path.GetFullPath(candidate); + var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath) + + System.IO.Path.DirectorySeparatorChar; + + if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase)) + { + SendResponse(context, 403, "Forbidden"); + return; + } + + // Missing static asset → fall back to index.html (SPA deep-link support) + if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty)) + { + resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + Debug.LogMessage(LogEventLevel.Debug, + "ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html", + requestedPath); + } + + if (!File.Exists(resolvedCandidate)) + { + SendResponse(context, 404, "Not Found"); + return; + } + + var ext = System.IO.Path.GetExtension(resolvedCandidate); + var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = contentType; + + if (TextExtensions.Contains(ext)) + { + string content; + using (var reader = new StreamReader(resolvedCandidate)) + content = reader.ReadToEnd(); + + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(content, false); + } + else + { + var bytes = System.IO.File.ReadAllBytes(resolvedCandidate); + context.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + + context.Response.End(); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}", + context.Request.RawUrl, ex.Message); + try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ } + } + } + + /// + /// Resolves the absolute path of the /HTML/debug folder on the processor. + /// + /// + /// On a 4-series appliance, Global.FilePathPrefix is + /// {root}/user/programX/, so walking up two parents gives the + /// processor root that contains the html folder. + /// On Virtual Control, Global.FilePathPrefix is {root}/User/, + /// so only one parent hop is needed. + /// + private static string GetHtmlDebugPath() + { + try + { + var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); + + DirectoryInfo rootDir; + if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) + { + // Virtual Control: {root}/User/ → one parent up = {root} + rootDir = programDir.Parent; + } + else + { + // 4-series appliance: {root}/user/programX/ → two parents up = {root} + rootDir = programDir.Parent?.Parent; + } + + if (rootDir == null) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'", + Global.FilePathPrefix); + return null; + } + + return System.IO.Path.Combine(rootDir.FullName, "html", "debug"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message); + return null; + } + } + + /// + /// Extracts the file sub-path from the request by parsing RawUrl. + /// Returns an empty string when the URL ends at /debug (root hit). + /// + private static string GetRequestedFilePath(HttpCwsContext context) + { + var rawUrl = context.Request.RawUrl ?? string.Empty; + + // Locate the /debug segment in the URL + const string debugToken = "/debug"; + var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return string.Empty; + + var afterDebug = rawUrl.Substring(idx + debugToken.Length); + + // Strip query string + var qIdx = afterDebug.IndexOf('?'); + if (qIdx >= 0) + afterDebug = afterDebug.Substring(0, qIdx); + + // Strip leading slash to get a relative file path + return afterDebug.TrimStart('/'); + } + + private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription) + { + context.Response.StatusCode = statusCode; + context.Response.StatusDescription = statusDescription; + context.Response.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 815aaa41..790fd013 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO.Compression; using System.Linq; using System.Reflection; using Crestron.SimplSharp; From b1a575e4d2dc6063e58900294af3d3ecbc399f1a Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 1 May 2026 14:43:48 -0600 Subject: [PATCH 2/4] feat: enhance URL generation in DebugWebsocketSink to support dual-stack environments --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 12b3d90a..b94d9b12 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -46,8 +46,15 @@ namespace PepperDash.Core { get { - if (_httpsServer == null) return ""; - return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; + if (_httpsServer == null || !_httpsServer.IsListening) return ""; + var service = _httpsServer.WebSocketServices[_path]; + if (service == null) return ""; + + // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments. + if (!string.IsNullOrEmpty(CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1))) + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)}:{_httpsServer.Port}{service.Path}"; + else + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}"; } } From 3b57860123a2a7a6c1d4db21b91de381abd61aca Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 4 May 2026 09:43:06 -0600 Subject: [PATCH 3/4] fix: correct response status description in LoginRequestHandler and improve error logging in ServeDebugAppRequestHandler --- .../Web/RequestHandlers/LoginRequestHandler.cs | 5 ++--- .../ServeDebugAppRequestHandler.cs | 15 ++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 8d724f11..5a4b7df7 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -71,7 +71,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { context.Response.StatusCode = 401; context.Response.StatusDescription = "Bad Request"; - context.Response.StatusDescription = "Unauthorized"; context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); @@ -80,8 +79,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } if (!token.Valid) - { - context.Response.StatusCode = 401; + { + context.Response.StatusCode = 401; context.Response.StatusDescription = "Unauthorized"; context.Response.End(); diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs index 3e48859e..2092c735 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -125,7 +125,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers if (TextExtensions.Contains(ext)) { string content; - using (var reader = new StreamReader(resolvedCandidate)) + using (var reader = new StreamReader(resolvedCandidate, Encoding.UTF8)) content = reader.ReadToEnd(); context.Response.ContentEncoding = Encoding.UTF8; @@ -141,10 +141,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } catch (Exception ex) { - Debug.LogMessage(LogEventLevel.Error, - "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}", - context.Request.RawUrl, ex.Message); - try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ } + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}'", + context.Request.RawUrl); + try { SendResponse(context, 500, "Internal Server Error"); } + catch { /* best-effort */ } } } @@ -189,8 +190,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } catch (Exception ex) { - Debug.LogMessage(LogEventLevel.Error, - "ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message); + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Error resolving HTML debug path"); return null; } } From 447af3883cb497c9f46b61683305ffa05c35d408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:48:12 +0000 Subject: [PATCH 4/4] fix: align GetHtmlDebugPath with AssetLoader two-hop path resolution Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/9d7d71b4-b083-412b-b7b2-3167561eeed3 Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> --- .../ServeDebugAppRequestHandler.cs | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs index 2092c735..d8a296e5 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -153,11 +153,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// Resolves the absolute path of the /HTML/debug folder on the processor. /// /// - /// On a 4-series appliance, Global.FilePathPrefix is - /// {root}/user/programX/, so walking up two parents gives the - /// processor root that contains the html folder. - /// On Virtual Control, Global.FilePathPrefix is {root}/User/, - /// so only one parent hop is needed. + /// Global.FilePathPrefix is always {root}/user/programX/ (or + /// equivalent), so walking up two parents gives the processor root that + /// contains the html folder. This mirrors the two-hop strategy used + /// by AssetLoader.ExtractDevToolsZip so that serving and extraction + /// always resolve to the same directory. /// private static string GetHtmlDebugPath() { @@ -166,17 +166,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); - DirectoryInfo rootDir; - if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) - { - // Virtual Control: {root}/User/ → one parent up = {root} - rootDir = programDir.Parent; - } - else - { - // 4-series appliance: {root}/user/programX/ → two parents up = {root} - rootDir = programDir.Parent?.Parent; - } + // Walk up two levels: {root}/user/programX/ → {root}/user/ → {root} + // This matches the path calculation used in AssetLoader.ExtractDevToolsZip. + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; if (rootDir == null) {