diff --git a/PepperDash.Essentials.4Series.sln b/PepperDash.Essentials.4Series.sln index fc8c20f1..a39555e8 100644 --- a/PepperDash.Essentials.4Series.sln +++ b/PepperDash.Essentials.4Series.sln @@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core.Tests", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Core.Tests", "src\PepperDash.Essentials.Core.Tests\PepperDash.Essentials.Core.Tests.csproj", "{F508E0BA-E885-424F-9D4C-359CF0011DEF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Tests", "src\PepperDash.Essentials.Tests\PepperDash.Essentials.Tests.csproj", "{841EE676-7784-4456-8F76-3697C6D432A6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU @@ -181,6 +183,24 @@ Global {F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU {F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x64.ActiveCfg = Release|Any CPU {F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x86.ActiveCfg = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.Build.0 = Debug|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.Build.0 = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.ActiveCfg = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.Build.0 = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.ActiveCfg = Release|Any CPU + {841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -194,6 +214,7 @@ Global {E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {680BA287-E61F-4B8D-BD7A-84C2504F5F9C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F508E0BA-E885-424F-9D4C-359CF0011DEF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {841EE676-7784-4456-8F76-3697C6D432A6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3} diff --git a/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs b/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs new file mode 100644 index 00000000..0d06bcb8 --- /dev/null +++ b/src/PepperDash.Essentials.Tests/ControlSystem/LoadAssetsTests.cs @@ -0,0 +1,334 @@ +using System.IO.Compression; +using FluentAssertions; +using Xunit; + +namespace PepperDash.Essentials.Tests.ControlSystem; + +/// +/// Tests for . +/// is the System.IO-based implementation that backs +/// . Tests run against a +/// temporary directory tree so no Crestron runtime is required. +/// Debug is initialised with fakes via TestInitializer. +/// +public sealed class LoadAssetsTests : IDisposable +{ + // --------------------------------------------------------------------------- + // Fixture: each test gets an isolated temp directory tree + // + // _rootDir/ + // appdir/ ← applicationDirectoryPath (where the loader scans) + // user/ + // program1/ ← filePathPrefix (where assets land) + // --------------------------------------------------------------------------- + + private readonly string _rootDir; + private readonly string _appDir; + private readonly string _filePathPrefix; + + public LoadAssetsTests() + { + _rootDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + _appDir = Path.Combine(_rootDir, "appdir"); + _filePathPrefix = Path.Combine(_rootDir, "user", "program1") + Path.DirectorySeparatorChar; + + Directory.CreateDirectory(_appDir); + Directory.CreateDirectory(_filePathPrefix); + } + + public void Dispose() + { + if (Directory.Exists(_rootDir)) + Directory.Delete(_rootDir, recursive: true); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static byte[] EmptyZip() + { + using var ms = new MemoryStream(); + using (var _ = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { } + return ms.ToArray(); + } + + private static byte[] ZipWithFile(string entryName, string content = "test") + { + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(entryName); + using var sw = new StreamWriter(entry.Open()); + sw.Write(content); + } + return ms.ToArray(); + } + + private static byte[] ZipWithDirectory(string directoryEntryName) + { + // Directory entries have a trailing slash and an empty Name + var normalised = directoryEntryName.TrimEnd('/') + '/'; + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntry(normalised); + } + return ms.ToArray(); + } + + private static byte[] ZipWithTraversalEntry() + { + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("../traversal.txt"); + using var sw = new StreamWriter(entry.Open()); + sw.Write("should not appear"); + } + return ms.ToArray(); + } + + private void WriteToAppDir(string fileName, byte[] contents) => + File.WriteAllBytes(Path.Combine(_appDir, fileName), contents); + + private void WriteTextToAppDir(string fileName, string text) => + File.WriteAllText(Path.Combine(_appDir, fileName), text); + + // --------------------------------------------------------------------------- + // No-op cases — nothing in the application directory + // --------------------------------------------------------------------------- + + [Fact] + public void LoadAssets_EmptyApplicationDirectory_DoesNotThrow() + { + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().NotThrow(); + } + + // --------------------------------------------------------------------------- + // assets*.zip + // --------------------------------------------------------------------------- + + [Fact] + public void LoadAssets_MultipleAssetsZips_ThrowsException() + { + WriteToAppDir("assets1.zip", EmptyZip()); + WriteToAppDir("assets2.zip", EmptyZip()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw().WithMessage("*Multiple assets zip files*"); + } + + [Fact] + public void LoadAssets_SingleAssetsZip_ExtractsFileToFilePathPrefix() + { + WriteToAppDir("assets.zip", ZipWithFile("config.json", "{}")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.Exists(Path.Combine(_filePathPrefix, "config.json")).Should().BeTrue(); + } + + [Fact] + public void LoadAssets_SingleAssetsZip_FileContentsArePreserved() + { + const string expected = "{\"key\":\"value\"}"; + WriteToAppDir("assets.zip", ZipWithFile("data.json", expected)); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.ReadAllText(Path.Combine(_filePathPrefix, "data.json")).Should().Be(expected); + } + + [Fact] + public void LoadAssets_SingleAssetsZip_ZipIsDeletedAfterExtraction() + { + var zipPath = Path.Combine(_appDir, "assets.zip"); + WriteToAppDir("assets.zip", ZipWithFile("file.txt")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.Exists(zipPath).Should().BeFalse("assets zip should be cleaned up after extraction"); + } + + [Fact] + public void LoadAssets_AssetsZipWithDirectoryEntry_CreatesDirectory() + { + WriteToAppDir("assets.zip", ZipWithDirectory("subdir/")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + Directory.Exists(Path.Combine(_filePathPrefix, "subdir/")).Should().BeTrue(); + } + + [Fact] + public void LoadAssets_AssetsZipWithPathTraversal_ThrowsInvalidOperationException() + { + WriteToAppDir("assets.zip", ZipWithTraversalEntry()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw() + .WithMessage("*trying to extract outside of the target directory*"); + } + + // --------------------------------------------------------------------------- + // htmlassets*.zip + // --------------------------------------------------------------------------- + + [Fact] + public void LoadAssets_MultipleHtmlAssetsZips_ThrowsException() + { + WriteToAppDir("htmlassets1.zip", EmptyZip()); + WriteToAppDir("htmlassets2.zip", EmptyZip()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw().WithMessage("*Multiple htmlassets zip files*"); + } + + [Fact] + public void LoadAssets_SingleHtmlAssetsZip_ExtractsToHtmlDirectory() + { + // htmlDir = rootDir/html + WriteToAppDir("htmlassets.zip", ZipWithFile("index.html", "")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + var expectedHtmlDir = Path.Combine(_rootDir, "html"); + File.Exists(Path.Combine(expectedHtmlDir, "index.html")).Should().BeTrue(); + } + + [Fact] + public void LoadAssets_SingleHtmlAssetsZip_ZipIsDeletedAfterExtraction() + { + var zipPath = Path.Combine(_appDir, "htmlassets.zip"); + WriteToAppDir("htmlassets.zip", ZipWithFile("page.html")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.Exists(zipPath).Should().BeFalse(); + } + + [Fact] + public void LoadAssets_HtmlAssetsZipWithPathTraversal_ThrowsInvalidOperationException() + { + WriteToAppDir("htmlassets.zip", ZipWithTraversalEntry()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw() + .WithMessage("*trying to extract outside of the target directory*"); + } + + [Fact] + public void LoadAssets_HtmlAssetsZip_ShallowFilePathPrefixThrowsWhenRootCannotBeDetermined() + { + // A path that is exactly ONE level below the filesystem root has no grandparent, + // so rootDir (programDir.Parent.Parent) is null and the guard should throw. + // DirectoryInfo works with non-existent paths, so we don't create the directory. + var filesystemRoot = Path.GetPathRoot(Path.GetTempPath())!; + var shallowPrefix = Path.Combine(filesystemRoot, "pepperDashTestShallow") + Path.DirectorySeparatorChar; + WriteToAppDir("htmlassets.zip", ZipWithFile("page.html")); + + var act = () => AssetLoader.Load(_appDir, shallowPrefix); + act.Should().Throw().WithMessage("*Unable to determine root directory for html extraction*"); + } + + // --------------------------------------------------------------------------- + // essentials-devtools*.zip + // --------------------------------------------------------------------------- + + [Fact] + public void LoadAssets_MultipleDevToolsZips_ThrowsException() + { + WriteToAppDir("essentials-devtools1.zip", EmptyZip()); + WriteToAppDir("essentials-devtools2.zip", EmptyZip()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw().WithMessage("*Multiple essentials-devtools zip files*"); + } + + [Fact] + public void LoadAssets_SingleDevToolsZip_ExtractsToHtmlDebugDirectory() + { + // debugDir = rootDir/html/debug + WriteToAppDir("essentials-devtools.zip", ZipWithFile("app.js", "console.log('hi');")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + var expectedDebugDir = Path.Combine(_rootDir, "html", "debug"); + File.Exists(Path.Combine(expectedDebugDir, "app.js")).Should().BeTrue(); + } + + [Fact] + public void LoadAssets_SingleDevToolsZip_ZipIsDeletedAfterExtraction() + { + var zipPath = Path.Combine(_appDir, "essentials-devtools.zip"); + WriteToAppDir("essentials-devtools.zip", ZipWithFile("tool.js")); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.Exists(zipPath).Should().BeFalse(); + } + + [Fact] + public void LoadAssets_DevToolsZipWithPathTraversal_ThrowsInvalidOperationException() + { + WriteToAppDir("essentials-devtools.zip", ZipWithTraversalEntry()); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw() + .WithMessage("*trying to extract outside of the target directory*"); + } + + // --------------------------------------------------------------------------- + // *configurationFile*.json + // --------------------------------------------------------------------------- + + [Fact] + public void LoadAssets_MultipleJsonConfigFiles_ThrowsException() + { + WriteTextToAppDir("abcconfigurationFile1.json", "{}"); + WriteTextToAppDir("abcconfigurationFile2.json", "{}"); + + var act = () => AssetLoader.Load(_appDir, _filePathPrefix); + act.Should().Throw().WithMessage("*Multiple configuration files found*"); + } + + [Fact] + public void LoadAssets_SingleJsonConfigFile_IsMovedToFilePathPrefix() + { + const string fileName = "myconfigurationFile.json"; + WriteTextToAppDir(fileName, "{}"); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.Exists(Path.Combine(_appDir, fileName)).Should().BeFalse("source file should be moved, not copied"); + File.Exists(Path.Combine(_filePathPrefix, fileName)).Should().BeTrue("file should exist at the file path prefix"); + } + + [Fact] + public void LoadAssets_SingleJsonConfigFile_ExistingDestinationIsReplaced() + { + const string fileName = "myconfigurationFile.json"; + WriteTextToAppDir(fileName, "new content"); + + // Pre-populate the destination with stale content + File.WriteAllText(Path.Combine(_filePathPrefix, fileName), "old content"); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be("new content"); + } + + [Fact] + public void LoadAssets_SingleJsonConfigFile_ContentIsPreserved() + { + const string content = "{\"devices\":[]}"; + const string fileName = "myconfigurationFile.json"; + WriteTextToAppDir(fileName, content); + + AssetLoader.Load(_appDir, _filePathPrefix); + + File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be(content); + } +} diff --git a/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs b/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs new file mode 100644 index 00000000..ba195788 --- /dev/null +++ b/src/PepperDash.Essentials.Tests/Fakes/Fakes.cs @@ -0,0 +1,61 @@ +using PepperDash.Core.Abstractions; + +namespace PepperDash.Essentials.Tests.Fakes; + +internal class FakeCrestronEnvironment : ICrestronEnvironment +{ + public DevicePlatform DevicePlatform { get; set; } = DevicePlatform.Appliance; + public RuntimeEnvironment RuntimeEnvironment { get; set; } = RuntimeEnvironment.SimplSharpPro; + public string NewLine { get; set; } = "\r\n"; + public uint ApplicationNumber { get; set; } = 1; + public uint RoomId { get; set; } = 0; + + public event EventHandler? ProgramStatusChanged + { + add { } + remove { } + } + + public event EventHandler? EthernetEventReceived + { + add { } + remove { } + } + + public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath(); + public bool IsHardwareRuntime => false; +} + +internal class NoOpCrestronConsole : ICrestronConsole +{ + public void PrintLine(string message) { } + public void Print(string message) { } + public void ConsoleCommandResponse(string message) { } + public void AddNewConsoleCommand(Action _, string __, string ___, ConsoleAccessLevel ____) { } +} + +internal class InMemoryCrestronDataStore : ICrestronDataStore +{ + private readonly Dictionary _store = new(); + + public void InitStore() { } + + public bool TryGetLocalInt(string key, out int value) + { + if (_store.TryGetValue(key, out var raw) && raw is int i) { value = i; return true; } + value = 0; + return false; + } + + public bool SetLocalInt(string key, int value) { _store[key] = value; return true; } + public bool SetLocalUint(string key, uint value) { _store[key] = (int)value; return true; } + + public bool TryGetLocalBool(string key, out bool value) + { + if (_store.TryGetValue(key, out var raw) && raw is bool b) { value = b; return true; } + value = false; + return false; + } + + public bool SetLocalBool(string key, bool value) { _store[key] = value; return true; } +} diff --git a/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj b/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj new file mode 100644 index 00000000..b32ff955 --- /dev/null +++ b/src/PepperDash.Essentials.Tests/PepperDash.Essentials.Tests.csproj @@ -0,0 +1,25 @@ + + + net9.0 + enable + enable + false + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/PepperDash.Essentials.Tests/TestInitializer.cs b/src/PepperDash.Essentials.Tests/TestInitializer.cs new file mode 100644 index 00000000..acf5e271 --- /dev/null +++ b/src/PepperDash.Essentials.Tests/TestInitializer.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; +using PepperDash.Core.Abstractions; +using PepperDash.Essentials.Tests.Fakes; + +namespace PepperDash.Essentials.Tests; + +/// +/// Runs once before any type in this assembly is accessed. +/// Registers fake Crestron service implementations so that the Debug static +/// constructor never tries to reach the real Crestron SDK. +/// +internal static class TestInitializer +{ + [ModuleInitializer] + internal static void Initialize() + { + DebugServiceRegistration.Register( + new FakeCrestronEnvironment + { + DevicePlatform = DevicePlatform.Server, + RuntimeEnvironment = RuntimeEnvironment.Other, + }, + new NoOpCrestronConsole(), + new InMemoryCrestronDataStore()); + } +} + + diff --git a/src/PepperDash.Essentials/AssetLoader.cs b/src/PepperDash.Essentials/AssetLoader.cs new file mode 100644 index 00000000..9db98c7e --- /dev/null +++ b/src/PepperDash.Essentials/AssetLoader.cs @@ -0,0 +1,256 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using PepperDash.Core; +using Serilog.Events; + +namespace PepperDash.Essentials; + +/// +/// Handles extracting embedded asset bundles and moving configuration files from the +/// application directory to the program file-path prefix at startup. +/// Implemented using System.IO types so it can run (and be tested) outside +/// of a Crestron runtime environment. +/// +internal static class AssetLoader +{ + /// + /// Scans for well-known zip bundles and + /// JSON configuration files and deploys them to . + /// + /// + /// The directory to scan (typically the Crestron application root). + /// + /// + /// The program's runtime working directory (e.g. /nvram/program1/). + /// + internal static void Load(string applicationDirectoryPath, string filePathPrefix) + { + var applicationDirectory = new DirectoryInfo(applicationDirectoryPath); + Debug.LogMessage(LogEventLevel.Information, + "Searching: {applicationDirectory:l} for embedded assets - {Destination}", + applicationDirectory.FullName, filePathPrefix); + + ExtractAssetsZip(applicationDirectory, filePathPrefix); + ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix); + ExtractDevToolsZip(applicationDirectory, filePathPrefix); + MoveConfigurationFile(applicationDirectory, filePathPrefix); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var zipFiles = applicationDirectory.GetFiles("assets*.zip"); + + if (zipFiles.Length > 1) + throw new Exception("Multiple assets zip files found. Cannot continue."); + + if (zipFiles.Length == 1) + { + var zipFile = zipFiles[0]; + var assetsRoot = Path.GetFullPath(filePathPrefix); + if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + assetsRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); + + using (var archive = ZipFile.OpenRead(zipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(filePathPrefix, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (Directory.Exists(destinationPath)) + Directory.Delete(destinationPath, recursive: true); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in zipFiles) + File.Delete(file.FullName); + } + + private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); + + if (htmlZipFiles.Length > 1) + throw new Exception( + "Multiple htmlassets zip files found in application directory. " + + "Please ensure only one htmlassets*.zip file is present and retry."); + + if (htmlZipFiles.Length == 1) + { + var htmlZipFile = htmlZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for html extraction. Current path: {filePathPrefix}"); + + var htmlDir = Path.Combine(rootDir.FullName, "html"); + var htmlRoot = Path.GetFullPath(htmlDir); + if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + htmlRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); + + using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(htmlDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in htmlZipFiles) + File.Delete(file.FullName); + } + + private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip"); + + if (devToolsZipFiles.Length > 1) + throw new Exception( + "Multiple essentials-devtools zip files found in application directory. " + + "Please ensure only one essentials-devtools*.zip file is present and retry."); + + if (devToolsZipFiles.Length == 1) + { + var devToolsZipFile = devToolsZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}"); + + var debugDir = Path.Combine(rootDir.FullName, "html", "debug"); + var debugRoot = Path.GetFullPath(debugDir); + if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + debugRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", + devToolsZipFile.FullName, debugDir); + + using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(debugDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in devToolsZipFiles) + File.Delete(file.FullName); + } + + private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); + + if (jsonFiles.Length > 1) + { + Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", + jsonFiles.Select(f => f.FullName).ToArray()); + throw new Exception("Multiple configuration files found. Cannot continue."); + } + + if (jsonFiles.Length == 1) + { + var jsonFile = jsonFiles[0]; + var finalPath = Path.Combine(filePathPrefix, jsonFile.Name); + Debug.LogMessage(LogEventLevel.Information, + "Found configuration file: {jsonFile:l}... Moving to: {Destination}", + jsonFile.FullName, finalPath); + + if (File.Exists(finalPath)) + { + Debug.LogMessage(LogEventLevel.Information, + "Removing existing configuration file: {Destination}", finalPath); + File.Delete(finalPath); + } + + jsonFile.MoveTo(finalPath); + } + } +} diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index c91f8927..52a47273 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -271,7 +271,7 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig _ = new Core.DeviceFactory(); - LoadAssets(); + LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); @@ -520,201 +520,7 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig } - private static void LoadAssets() - { - var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix); - Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix); - - var zipFiles = applicationDirectory.GetFiles("assets*.zip"); - - if (zipFiles.Length > 1) - { - throw new Exception("Multiple assets zip files found. Cannot continue."); - } - - if (zipFiles.Length == 1) - { - var zipFile = zipFiles[0]; - var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix); - if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - assetsRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); - using (var archive = ZipFile.OpenRead(zipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // If a directory exists where a file should go, delete it - if (Directory.Exists(destinationPath)) - Directory.Delete(destinationPath, true); - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up zip files - foreach (var file in zipFiles) - { - File.Delete(file.FullName); - } - - var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); - - if (htmlZipFiles.Length > 1) - { - throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry."); - } - - - if (htmlZipFiles.Length == 1) - { - var htmlZipFile = htmlZipFiles[0]; - var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var userOrNvramDir = programDir.Parent; - var rootDir = userOrNvramDir?.Parent; - if (rootDir == null) - { - throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}"); - } - var htmlDir = Path.Combine(rootDir.FullName, "html"); - var htmlRoot = System.IO.Path.GetFullPath(htmlDir); - if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && - !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - htmlRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); - using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(htmlDir, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // Only delete the file if it exists and is a file, not a directory - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up html zip files - foreach (var file in htmlZipFiles) - { - File.Delete(file.FullName); - } - - var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip"); - - if (devToolsZipFiles.Length > 1) - { - throw new Exception("Multiple essentials-devtools zip files found in application directory. Please ensure only one essentials-devtools*.zip file is present and retry."); - } - - if (devToolsZipFiles.Length == 1) - { - var devToolsZipFile = devToolsZipFiles[0]; - var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var userOrNvramDir = programDir.Parent; - var rootDir = userOrNvramDir?.Parent; - if (rootDir == null) - { - throw new Exception($"Unable to determine root directory for debug html extraction. Current path: {Global.FilePathPrefix}"); - } - var debugDir = Path.Combine(Path.Combine(rootDir.FullName, "html"), "debug"); - var debugRoot = System.IO.Path.GetFullPath(debugDir); - if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && - !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - debugRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", devToolsZipFile.FullName, debugDir); - using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(debugDir, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up devtools zip files - foreach (var file in devToolsZipFiles) - { - File.Delete(file.FullName); - } - - var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); - - if (jsonFiles.Length > 1) - { - Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray()); - throw new Exception("Multiple configuration files found. Cannot continue."); - } - - if (jsonFiles.Length == 1) - { - var jsonFile = jsonFiles[0]; - var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name); - Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath); - - if (File.Exists(finalPath)) - { - Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath); - File.Delete(finalPath); - } - - jsonFile.MoveTo(finalPath); - } - } + internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) => + AssetLoader.Load(applicationDirectoryPath, filePathPrefix); } diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj index d122c7b0..4bfd1f62 100644 --- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj +++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj @@ -27,6 +27,11 @@ pdbonly bin\$(Configuration)\PepperDashEssentials.xml + + + <_Parameter1>PepperDash.Essentials.Tests + +