diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ef8e86b0..bdc59cb9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ PepperDash Technology PepperDash Technology PepperDash Essentials - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/Essentials git Crestron; 4series @@ -20,4 +20,7 @@ + + + diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs index a1afba93..0dd39fee 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs @@ -132,12 +132,14 @@ namespace PepperDash.Essentials.Core.Config; { var parsedConfig = JObject.Parse(fileContents); - // Check if it's a v2 config (check for "version" node) - // this means it's already merged by the Portal API - // from the v2 config tool - var isV2Config = parsedConfig["versions"] != null; + // A config is v1 if it has separate "system" and "template" nodes that + // need to be merged. A v2 config is already merged by the Portal API and + // will not have "system"/"template" nodes. This is independent of whether + // a "versions" node is present, which only carries version metadata and + // can appear on either a v1 or v2 config. + var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; - if (isV2Config) + if (!isV1Config) { Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); ConfigObject = parsedConfig.ToObject(); @@ -145,6 +147,8 @@ namespace PepperDash.Essentials.Core.Config; return ConfigObject != null; } + Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template."); + // Extract SystemUrl and TemplateUrl into final config output ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject(); @@ -163,6 +167,13 @@ namespace PepperDash.Essentials.Core.Config; { ConfigObject.TemplateUrl = parsedConfig["template_url"].Value(); } + + // MergeConfigs does not carry the "versions" node forward, so it must be + // applied separately to ensure it's preserved in the merged config. + if (parsedConfig["versions"] != null) + { + ConfigObject.Versions = parsedConfig["versions"].ToObject(); + } } Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index 0fb0c93e..bcfd6227 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -16,9 +16,15 @@ namespace PepperDash.Essentials.Core.Config; /// public class EssentialsConfig : BasicConfig { + /// + /// Gets or sets the System URL for the Essentials configuration + /// [JsonProperty("system_url")] public string SystemUrl { get; set; } + /// + /// Gets or sets the Template URL for the Essentials configuration + /// [JsonProperty("template_url")] public string TemplateUrl { get; set; } @@ -74,14 +80,21 @@ public class EssentialsConfig : BasicConfig } } + /// + /// Gets or sets the list of rooms (device configurations) + /// [JsonProperty("rooms")] public List Rooms { get; set; } /// /// Gets or sets the Versions /// + [JsonProperty("versions")] public VersionData Versions { get; set; } + /// + /// Initializes a new instance of the class. + /// public EssentialsConfig() : base() { @@ -98,13 +111,13 @@ public class VersionData /// Gets or sets the Essentials version /// [JsonProperty("essentials")] - public NugetVersion Essentials { get; set; } + public PackageVersion Essentials { get; set; } /// /// Gets or sets the list of UserInterfaces /// [JsonProperty("userInterfaces")] - public List UserInterfaces { get; set; } + public List UserInterfaces { get; set; } /// /// Gets or sets the TouchpanelWrapperApp version @@ -112,45 +125,29 @@ public class VersionData /// to run an HTML5 user interface. /// [JsonProperty("touchpanelWrapperApp")] - public AppVersion TouchpanelWrapperApp { get; set; } + public PackageVersion TouchpanelWrapperApp { get; set; } /// /// Gets or sets the list of Packages /// [JsonProperty("packages")] - public List Packages { get; set; } + public List Packages { get; set; } /// /// Initializes a new instance of the class. /// public VersionData() { - UserInterfaces = new List(); - TouchpanelWrapperApp = new AppVersion(); - Packages = new List(); + UserInterfaces = new List(); + TouchpanelWrapperApp = new PackageVersion(); + Packages = new List(); } } -public class AppVersion -{ - /// - /// Gets or sets the Version - /// - [JsonProperty("version")] - public string Version { get; set; } - - /// - /// Gets or sets the RepoUrl - /// - [JsonProperty("repoUrl")] - public string RepoUrl { get; set; } -} - - /// -/// Represents a NugetVersion +/// Represents a Package Version /// -public class NugetVersion +public class PackageVersion { /// /// Gets or sets the Version @@ -161,14 +158,20 @@ public class NugetVersion /// /// Gets or sets the PackageId /// - [JsonProperty("packageId")] + [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] public string PackageId { get; set; } /// /// Gets or sets the RepoUrl /// - [JsonProperty("repoUrl")] + [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)] public string RepoUrl { get; set; } + + /// + /// Gets or sets the human-readable name + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } } /// @@ -176,6 +179,9 @@ public class NugetVersion /// public class SystemTemplateConfigs { + /// + /// Gets or sets the System configuration + /// public EssentialsConfig System { get; set; } /// diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 86f01803..9c16d47e 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -90,6 +90,11 @@ public class EssentialsWebApi : EssentialsDevice Name = "ReportVersions", RouteHandler = new ReportVersionsRequestHandler() }, + new HttpCwsRoute("packageManifest") + { + Name = "GetPackageManifest", + RouteHandler = new GetPackageManifestRequestHandler() + }, new HttpCwsRoute("appdebug") { Name = "AppDebug", diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs new file mode 100644 index 00000000..f89b0919 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,261 @@ +using System; +using System.Linq; +using System.Reflection; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Config; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers; + +/// +/// Represents a GetPackageManifestRequestHandler +/// +public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler +{ + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) + { + } + + /// + /// Handles GET method requests + /// + /// + protected override void HandleGet(HttpCwsContext context) + { + try + { + var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); + + PopulateEssentials(result); + PopulatePackages(result); + + var js = JsonConvert.SerializeObject(result, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(js, false); + context.Response.End(); + } + catch (Exception ex) + { + PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request"); + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.End(); + } + } + + /// + /// Deep-copies the config's VersionData so the live config object is never mutated + /// + private static VersionData CloneVersionData(VersionData source) + { + if (source == null) + { + return null; + } + + var json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly + /// + private static void PopulateEssentials(VersionData result) + { + var essentials = result.Essentials ?? new PackageVersion(); + + essentials.Version = Global.AssemblyVersion; + + // The main program assembly (PackageId "PepperDashEssentials") is what's actually published + // to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that + // project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly + // is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up + // directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata + // (every project's .csproj sets its own PackageId explicitly), falling back to this handler's + // own (Core) assembly if it can't be found. + var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + ?? typeof(GetPackageManifestRequestHandler).Assembly; + + var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); + if (!string.IsNullOrEmpty(repoUrl)) + { + essentials.RepoUrl = repoUrl; + } + + var name = GetAssemblyProduct(essentialsAssembly); + if (!string.IsNullOrEmpty(name)) + { + essentials.Name = name; + } + + // Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the + // authoritative source, matching the actual published PackageId) over any config-supplied + // or hardcoded value. + var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId"); + if (!string.IsNullOrEmpty(reflectedPackageId)) + { + essentials.PackageId = reflectedPackageId; + } + else if (string.IsNullOrEmpty(essentials.PackageId)) + { + essentials.PackageId = "PepperDashEssentials"; + } + + result.Essentials = essentials; + } + + /// + /// Merges reflection data from loaded plugin assemblies with the config's packages list + /// + private static void PopulatePackages(VersionData result) + { + // Filter out null entries defensively - the packages list is deserialized from user-editable + // config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint. + var configPackages = (result.Packages ?? new System.Collections.Generic.List()) + .Where(p => p != null) + .ToList(); + var matchedConfigPackages = new System.Collections.Generic.HashSet(); + var mergedPackages = new System.Collections.Generic.List(); + + foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null)) + { + var reflectedVersion = loaded.Version; + if (string.IsNullOrEmpty(reflectedVersion)) + { + // Never emit an entry with no version - the extension's parser drops entries + // whose version isn't a string. + continue; + } + + var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); + var reflectedName = GetAssemblyProduct(loaded.Assembly); + + // Plugins built from a Directory.Build.props that embeds + // carry their PackageId + // directly - this is authoritative and should be preferred over the title/name fallback chain. + var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId"); + + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); + var assemblyName = loaded.Assembly.GetName().Name; + var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); + + var match = configPackages.FirstOrDefault(p => + !matchedConfigPackages.Contains(p) && + !string.IsNullOrEmpty(p.PackageId) && + (string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase))); + + if (match != null) + { + matchedConfigPackages.Add(match); + + mergedPackages.Add(new PackageVersion + { + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName, + RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, + PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId, + Version = reflectedVersion + }); + } + else + { + // Loaded but not present (or not matched) in config - emit the reflected PackageId + // when the assembly carries one, otherwise leave it null as before. + mergedPackages.Add(new PackageVersion + { + Name = reflectedName, + RepoUrl = reflectedRepoUrl, + PackageId = reflectedPackageId, + Version = reflectedVersion, + }); + } + } + + // Configured but not currently loaded - pass through unchanged, but keep the same + // "never emit an entry with no version" guarantee as the loaded-reflection branch above - + // otherwise this branch reintroduces the schema issue that guard is meant to prevent. + mergedPackages.AddRange(configPackages.Where(p => + !matchedConfigPackages.Contains(p) && !string.IsNullOrEmpty(p.Version))); + + result.Packages = mergedPackages; + } + + private static string GetAssemblyMetadataValue(Assembly assembly, string key) + { + if (assembly == null) + { + return null; + } + + var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .Cast() + .FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase)); + + return match?.Value; + } + + private static string GetAssemblyProduct(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false) + .FirstOrDefault() as AssemblyProductAttribute; + + return attribute?.Product; + } + + private static string GetAssemblyTitle(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false) + .FirstOrDefault() as AssemblyTitleAttribute; + + return attribute?.Title; + } + + private static string StripTrailingSeriesSuffix(string assemblyName) + { + const string suffix = ".4Series"; + + if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return assemblyName; + } + + return assemblyName.Substring(0, assemblyName.Length - suffix.Length); + } + + private static string TrimTrailingGit(string repoUrl) + { + const string suffix = ".git"; + + if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return repoUrl; + } + + return repoUrl.Substring(0, repoUrl.Length - suffix.Length); + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index f5c5f096..ea3b02d1 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -1006,6 +1006,14 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var expectedAppVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + var userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator; + var versionCheck = TouchpanelWrapperAppVersionChecker.CheckDeployedVersion(userAppPath, expectedAppVersion); + + CrestronConsole.ConsoleCommandResponse( + $"\r\nUI Wrapper App Deployed Version Check:\r\n {versionCheck.Summary}\r\n" + ); } /// diff --git a/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs b/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs new file mode 100644 index 00000000..1368b788 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Linq; + +namespace PepperDash.Essentials +{ + /// + /// Determines whether the touchpanel wrapper app (the mobile control React app) deployed to this + /// processor's mcUserApp folder matches the version configured in the system config's + /// versions.touchpanelWrapperApp. + /// + /// + /// This reads the app's built .js/.html files directly from disk, so unlike the previous + /// client-self-reported-version approach, it does not depend on any UI client being connected and + /// does not accumulate per-client state over the life of the program. + /// + public static class TouchpanelWrapperAppVersionChecker + { + private static readonly string[] SearchPatterns = { "*.js", "*.html" }; + + /// + /// Scans the deployed touchpanel wrapper app's .js/.html files under + /// for the literal string (the version build tooling bakes + /// into the app at build time). + /// + /// The path to the deployed mcUserApp folder + /// The expected version, from config's versions.touchpanelWrapperApp.version + public static TouchpanelWrapperAppVersionCheckResult CheckDeployedVersion(string appPath, string expectedVersion) + { + if (string.IsNullOrEmpty(expectedVersion)) + { + return TouchpanelWrapperAppVersionCheckResult.NotConfigured(appPath); + } + + if (string.IsNullOrEmpty(appPath) || !Directory.Exists(appPath)) + { + return TouchpanelWrapperAppVersionCheckResult.AppNotDeployed(appPath, expectedVersion); + } + + try + { + var files = SearchPatterns + .SelectMany(pattern => Directory.GetFiles(appPath, pattern, SearchOption.AllDirectories)) + .ToList(); + + if (files.Count == 0) + { + return TouchpanelWrapperAppVersionCheckResult.AppNotDeployed(appPath, expectedVersion); + } + + foreach (var file in files) + { + var contents = File.ReadAllText(file); + if (contents.IndexOf(expectedVersion, StringComparison.OrdinalIgnoreCase) >= 0) + { + return TouchpanelWrapperAppVersionCheckResult.Match(appPath, expectedVersion, file); + } + } + + return TouchpanelWrapperAppVersionCheckResult.Mismatch(appPath, expectedVersion, files.Count); + } + catch (Exception ex) + { + return TouchpanelWrapperAppVersionCheckResult.Error(appPath, expectedVersion, ex.Message); + } + } + } + + /// + /// The outcome of a check + /// + public class TouchpanelWrapperAppVersionCheckResult + { + /// + /// The mcUserApp path that was checked + /// + public string AppPath { get; } + + /// + /// The expected version from config, if any was configured + /// + public string ExpectedVersion { get; } + + /// + /// True if any files were found deployed at + /// + public bool AppDeployed { get; } + + /// + /// True if was found in one of the deployed files + /// + public bool VersionMatched { get; } + + /// + /// The file the expected version was found in, if is true + /// + public string MatchedFile { get; } + + /// + /// The number of files scanned + /// + public int FilesScanned { get; } + + /// + /// A human-readable summary of the outcome, suitable for console output + /// + public string Summary { get; } + + private TouchpanelWrapperAppVersionCheckResult(string appPath, string expectedVersion, bool appDeployed, + bool versionMatched, string matchedFile, int filesScanned, string summary) + { + AppPath = appPath; + ExpectedVersion = expectedVersion; + AppDeployed = appDeployed; + VersionMatched = versionMatched; + MatchedFile = matchedFile; + FilesScanned = filesScanned; + Summary = summary; + } + + internal static TouchpanelWrapperAppVersionCheckResult NotConfigured(string appPath) => + new TouchpanelWrapperAppVersionCheckResult(appPath, null, false, false, null, 0, + "versions.touchpanelWrapperApp.version is not configured; skipping check"); + + internal static TouchpanelWrapperAppVersionCheckResult AppNotDeployed(string appPath, string expectedVersion) => + new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, false, false, null, 0, + $"No app files found at '{appPath}'; expected version {expectedVersion}"); + + internal static TouchpanelWrapperAppVersionCheckResult Match(string appPath, string expectedVersion, string matchedFile) => + new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, true, true, matchedFile, 1, + $"Deployed app matches configured version {expectedVersion} (found in '{matchedFile}')"); + + internal static TouchpanelWrapperAppVersionCheckResult Mismatch(string appPath, string expectedVersion, int filesScanned) => + new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, true, false, null, filesScanned, + $"Deployed app at '{appPath}' does NOT contain expected version {expectedVersion} ({filesScanned} file(s) scanned) - MISMATCH"); + + internal static TouchpanelWrapperAppVersionCheckResult Error(string appPath, string expectedVersion, string errorMessage) => + new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, false, false, null, 0, + $"Error checking deployed app version at '{appPath}': {errorMessage}"); + } +}