From f0025ce56cceb0a9bb681bf770d2e5315c1f78e4 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Wed, 8 Jul 2026 22:49:29 -0500 Subject: [PATCH 1/5] Backport PR #1439: packageManifest CWS endpoint + v1/v2 config merge fix Backports PepperDash/Essentials#1439 (merged to main at 6ceddcf5) onto dev/v3-routing (v3.0.0-dev-v3-routing.40): - New GET /packageManifest CWS route reporting Essentials/plugin/touchpanel UI package version info (GetPackageManifestRequestHandler). - EssentialsConfig.Versions now round-trips via [JsonProperty("versions")]. - ConfigReader now detects v1 vs v2 config by presence of system/template nodes (previously any versions node caused the v1->v2 merge to be skipped), and re-applies the versions node after merge since PortalConfigReader.MergeConfigs does not carry it forward. - Mobile control: track and validate UI client app versions (ConnectedClientVersionInfo, MobileControlSystemController changes merged cleanly from the PR). - Directory.Build.props: adds PackageId AssemblyMetadata item (kept this branch's 3.0.0-local version). Conflict resolution notes: - This branch had already introduced its own AppVersion type (Version + RepoUrl only) for TouchpanelWrapperApp/UserInterfaces. Replaced it with the PR's unified NugetVersion (adds PackageId/RepoUrl/Name, all NullValueHandling.Ignore) to match main's schema, since AppVersion had no other references in the codebase. - ConfigReader.LoadConfig on this branch had already fixed a double Stream.ReadToEnd() bug (reading fileContents once); kept that fix and layered the PR's v1/v2 detection fix + versions-node preservation on top of it, plus this branch's null-ConfigObject-after-merge guard. - EssentialsWebApi.SetupRoutes: inserted the new packageManifest route into this branch's existing (longer) route list, which already includes routing-specific endpoints not present on main. Builds clean (dotnet build on the full solution, 0 errors). Not pushed - pending local hardware/config testing before push. --- src/Directory.Build.props | 5 +- .../Config/Essentials/ConfigReader.cs | 21 +- .../Config/Essentials/EssentialsConfig.cs | 60 ++-- .../Web/EssentialsWebApi.cs | 5 + .../GetPackageManifestRequestHandler.cs | 259 ++++++++++++++++++ .../ConnectedClientVersionInfo.cs | 61 +++++ .../MobileControlSystemController.cs | 89 ++++++ 7 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs create mode 100644 src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs 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..3d1b9eaa --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,259 @@ +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 + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + 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/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs new file mode 100644 index 00000000..68f5e36e --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -0,0 +1,61 @@ +using System; +using Newtonsoft.Json; + +namespace PepperDash.Essentials +{ + /// + /// Represents the version information reported by a connected Mobile Control UI client + /// + public class ConnectedClientVersionInfo + { + /// + /// Gets or sets the client id + /// + [JsonProperty("clientId")] + public string ClientId { get; set; } + + /// + /// Gets or sets the room key the client joined + /// + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + /// + /// Gets or sets the touchpanel key the client joined as, if any + /// + [JsonProperty("touchpanelKey")] + public string TouchpanelKey { get; set; } + + /// + /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) + /// + [JsonProperty("appVersion")] + public string AppVersion { get; set; } + + /// + /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured + /// + [JsonProperty("expectedAppVersion")] + public string ExpectedAppVersion { get; set; } + + /// + /// Gets or sets the UTC time the client last reported this version + /// + [JsonProperty("lastSeen")] + public DateTime LastSeen { get; set; } + + /// + /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate + /// without affecting the internally tracked instance + /// + public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo + { + ClientId = ClientId, + RoomKey = RoomKey, + TouchpanelKey = TouchpanelKey, + AppVersion = AppVersion, + ExpectedAppVersion = ExpectedAppVersion, + LastSeen = LastSeen + }; + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index f5c5f096..8de9d9eb 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -58,11 +58,32 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + private readonly Dictionary _connectedClientVersions = + new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + private readonly object _connectedClientVersionsLock = new object(); + /// /// Get the custom messengers /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers); + /// + /// Gets the most recently reported UI app version for each connected client, keyed by clientId + /// + public ReadOnlyDictionary ConnectedClientVersions + { + get + { + lock (_connectedClientVersionsLock) + { + return new ReadOnlyDictionary( + _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) + ); + } + } + } + /// /// Get the default messengers /// @@ -1006,6 +1027,28 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var connectedClientVersions = ConnectedClientVersions; + + if (connectedClientVersions.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); + } + else + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); + foreach (var kv in connectedClientVersions) + { + var v = kv.Value; + var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); + + CrestronConsole.ConsoleCommandResponse( + $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + + $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" + ); + } + } } /// @@ -1402,6 +1445,8 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); + TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); + if (_roomCombiner == null) { var message = new MobileControlMessage @@ -1473,6 +1518,50 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } + /// + /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's + /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. + /// + private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) + { + if (string.IsNullOrEmpty(appVersion)) + { + return; + } + + var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + + var info = new ConnectedClientVersionInfo + { + ClientId = clientId, + RoomKey = roomKey, + TouchpanelKey = touchpanelKey, + AppVersion = appVersion, + ExpectedAppVersion = expectedVersion, + LastSeen = DateTime.UtcNow + }; + + lock (_connectedClientVersionsLock) + { + _connectedClientVersions[clientId] = info; + } + + if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) + { + this.LogWarning( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", + clientId, touchpanelKey, appVersion, expectedVersion + ); + } + else + { + this.LogVerbose( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", + clientId, touchpanelKey, appVersion + ); + } + } + private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) From 2d8e42f566c185c0acf25c28d5f1daddbed8b885 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Wed, 8 Jul 2026 23:16:06 -0500 Subject: [PATCH 2/5] style: use file-scoped namespace in GetPackageManifestRequestHandler Matches the convention used by the other request handlers in this folder (ReportVersionsRequestHandler.cs, AppDebugRequestHandler.cs, etc). Addresses PR #1441 review comment. --- .../GetPackageManifestRequestHandler.cs | 481 +++++++++--------- 1 file changed, 240 insertions(+), 241 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 3d1b9eaa..284f3e2d 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -6,254 +6,253 @@ using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; using PepperDash.Essentials.Core.Config; -namespace PepperDash.Essentials.Core.Web.RequestHandlers +namespace PepperDash.Essentials.Core.Web.RequestHandlers; + +/// +/// Represents a GetPackageManifestRequestHandler +/// +public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler { /// - /// Represents a GetPackageManifestRequestHandler + /// Constructor /// - public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) { - /// - /// 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(); } - - /// - /// Handles GET method requests - /// - /// - protected override void HandleGet(HttpCwsContext context) + catch (Exception ex) { - 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 - mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); - - 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); + 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 + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + 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); + } } From e7406dbeade8ba45a00aee83e59e03e7b08670e5 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Fri, 10 Jul 2026 09:51:23 -0500 Subject: [PATCH 3/5] ci(force-patch): increment patch by force From 4631d3a5375728e23ddb49d93e5d612fe5ccbd85 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Fri, 10 Jul 2026 12:25:43 -0500 Subject: [PATCH 4/5] refactor: replace client-reported UI app version tracking with disk-based check Remove ConnectedClientVersionInfo and the per-client version dictionary in MobileControlSystemController, which grew unbounded and depended on a UI client connecting and self-reporting its APP_VERSION. Add TouchpanelWrapperAppVersionChecker, which instead scans the deployed mcUserApp files on disk for the configured versions.touchpanelWrapperApp version string. This works without any client connected and doesn't accumulate state. Wired into the mobileinfo console command output. --- .../ConnectedClientVersionInfo.cs | 61 -------- .../MobileControlSystemController.cs | 93 +----------- .../TouchpanelWrapperAppVersionChecker.cs | 141 ++++++++++++++++++ 3 files changed, 147 insertions(+), 148 deletions(-) delete mode 100644 src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs create mode 100644 src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs deleted file mode 100644 index 68f5e36e..00000000 --- a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace PepperDash.Essentials -{ - /// - /// Represents the version information reported by a connected Mobile Control UI client - /// - public class ConnectedClientVersionInfo - { - /// - /// Gets or sets the client id - /// - [JsonProperty("clientId")] - public string ClientId { get; set; } - - /// - /// Gets or sets the room key the client joined - /// - [JsonProperty("roomKey")] - public string RoomKey { get; set; } - - /// - /// Gets or sets the touchpanel key the client joined as, if any - /// - [JsonProperty("touchpanelKey")] - public string TouchpanelKey { get; set; } - - /// - /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) - /// - [JsonProperty("appVersion")] - public string AppVersion { get; set; } - - /// - /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured - /// - [JsonProperty("expectedAppVersion")] - public string ExpectedAppVersion { get; set; } - - /// - /// Gets or sets the UTC time the client last reported this version - /// - [JsonProperty("lastSeen")] - public DateTime LastSeen { get; set; } - - /// - /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate - /// without affecting the internally tracked instance - /// - public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo - { - ClientId = ClientId, - RoomKey = RoomKey, - TouchpanelKey = TouchpanelKey, - AppVersion = AppVersion, - ExpectedAppVersion = ExpectedAppVersion, - LastSeen = LastSeen - }; - } -} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 8de9d9eb..ea3b02d1 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -58,32 +58,11 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); - private readonly Dictionary _connectedClientVersions = - new Dictionary(StringComparer.InvariantCultureIgnoreCase); - - private readonly object _connectedClientVersionsLock = new object(); - /// /// Get the custom messengers /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers); - /// - /// Gets the most recently reported UI app version for each connected client, keyed by clientId - /// - public ReadOnlyDictionary ConnectedClientVersions - { - get - { - lock (_connectedClientVersionsLock) - { - return new ReadOnlyDictionary( - _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) - ); - } - } - } - /// /// Get the default messengers /// @@ -1028,27 +1007,13 @@ namespace PepperDash.Essentials ); } - var connectedClientVersions = ConnectedClientVersions; + var expectedAppVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + var userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator; + var versionCheck = TouchpanelWrapperAppVersionChecker.CheckDeployedVersion(userAppPath, expectedAppVersion); - if (connectedClientVersions.Count == 0) - { - CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); - } - else - { - CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); - foreach (var kv in connectedClientVersions) - { - var v = kv.Value; - var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); - - CrestronConsole.ConsoleCommandResponse( - $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + - $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + - $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" - ); - } - } + CrestronConsole.ConsoleCommandResponse( + $"\r\nUI Wrapper App Deployed Version Check:\r\n {versionCheck.Summary}\r\n" + ); } /// @@ -1445,8 +1410,6 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); - TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); - if (_roomCombiner == null) { var message = new MobileControlMessage @@ -1518,50 +1481,6 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } - /// - /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's - /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. - /// - private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) - { - if (string.IsNullOrEmpty(appVersion)) - { - return; - } - - var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; - - var info = new ConnectedClientVersionInfo - { - ClientId = clientId, - RoomKey = roomKey, - TouchpanelKey = touchpanelKey, - AppVersion = appVersion, - ExpectedAppVersion = expectedVersion, - LastSeen = DateTime.UtcNow - }; - - lock (_connectedClientVersionsLock) - { - _connectedClientVersions[clientId] = info; - } - - if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) - { - this.LogWarning( - "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", - clientId, touchpanelKey, appVersion, expectedVersion - ); - } - else - { - this.LogVerbose( - "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", - clientId, touchpanelKey, appVersion - ); - } - } - private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) 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}"); + } +} From 4a366a5a51ece5a4767939970a32b613b1ad4c95 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Fri, 10 Jul 2026 13:03:28 -0500 Subject: [PATCH 5/5] fix: guard against empty Version in configured-but-not-loaded package passthrough PopulatePackages' "configured but not currently loaded" pass-through could still emit PackageVersion entries with a null/empty Version, reintroducing the schema issue the loaded-reflection branch's own guard already prevents (downstream parsers drop entries whose version isn't a string). --- .../RequestHandlers/GetPackageManifestRequestHandler.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 284f3e2d..f89b0919 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -186,8 +186,11 @@ public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler } } - // Configured but not currently loaded - pass through unchanged - mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + // 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; }